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.
@@ -49,9 +49,61 @@ export type TurbineConfig = TurbineCliConfig;
49
49
  * silently matches zero tables. Used by `turbine generate` to fail loudly.
50
50
  */
51
51
  export declare function looksLikeSchemaFilePath(schema: string): boolean;
52
+ /** A config-file load attempt that failed, kept so the CLI can surface it. */
53
+ export interface ConfigLoadError {
54
+ /** The config file whose import threw (e.g. `turbine.config.ts`). */
55
+ filename: string;
56
+ /** The underlying error thrown by the dynamic import. */
57
+ error: unknown;
58
+ }
59
+ /** Result of {@link loadConfigResult}: the resolved config plus any load failure. */
60
+ export interface ConfigLoadResult {
61
+ config: TurbineCliConfig;
62
+ /**
63
+ * Set when a config file existed but failed to import. The config is still
64
+ * returned as `{}` so resolution falls through to env vars and CLI flags, but
65
+ * the CLI should surface this rather than let it masquerade as a missing URL.
66
+ */
67
+ loadError?: ConfigLoadError;
68
+ }
69
+ /**
70
+ * Unwrap the module object returned by `import(configFile)` down to the actual
71
+ * config value.
72
+ *
73
+ * With `"type": "commonjs"` in the consumer's package.json (the `npm init -y`
74
+ * default) plus the tsx loader, importing `turbine.config.ts` yields a
75
+ * CJS-interop DOUBLE-wrapped default: `mod.default` is itself `{ default: config }`.
76
+ * A naive `mod.default ?? mod` then reads every field as `undefined`, so every
77
+ * command fails with a misleading "No database URL provided".
78
+ *
79
+ * This prefers `default` when present (the historical behavior) and then keeps
80
+ * descending through any additional pure `{ default: … }` wrappers, so both the
81
+ * correct single-default shape and the double-wrapped shape resolve to the same
82
+ * config. A genuine config object (which has real fields, never a lone
83
+ * `default`) is returned untouched.
84
+ */
85
+ export declare function unwrapModuleDefault(mod: unknown): unknown;
86
+ /**
87
+ * {@link unwrapModuleDefault} specialized for config files: a non-object export
88
+ * collapses to `{}` so downstream resolution falls through to env vars/flags.
89
+ */
90
+ export declare function unwrapConfigModule(mod: unknown): TurbineCliConfig;
91
+ /**
92
+ * Attempt to load a turbine config file from the given directory, returning the
93
+ * resolved config together with any load failure so the caller can surface it.
94
+ *
95
+ * Candidates are tried in {@link CONFIG_FILES} priority order. The first one
96
+ * that imports successfully wins. If a candidate exists but throws (syntax
97
+ * error, ESM/CJS interop failure, etc.) we remember the first such error and
98
+ * keep trying lower-priority candidates; if none load, the remembered error is
99
+ * returned in `loadError` while `config` stays `{}` so env/flag resolution can
100
+ * still proceed.
101
+ */
102
+ export declare function loadConfigResult(cwd?: string): Promise<ConfigLoadResult>;
52
103
  /**
53
104
  * Attempt to load a turbine config file from the current directory.
54
- * Returns the config if found, or an empty object.
105
+ * Returns the config if found, or an empty object. Load failures are swallowed
106
+ * here; callers that need to surface them should use {@link loadConfigResult}.
55
107
  */
56
108
  export declare function loadConfig(cwd?: string): Promise<TurbineCliConfig>;
57
109
  /**
@@ -25,15 +25,66 @@ export function looksLikeSchemaFilePath(schema) {
25
25
  // ---------------------------------------------------------------------------
26
26
  const CONFIG_FILES = ['turbine.config.ts', 'turbine.config.mts', 'turbine.config.js', 'turbine.config.mjs'];
27
27
  const DEFAULT_SEED_CANDIDATES = ['seed.ts', 'seed.js', 'seed.sql'];
28
- // ---------------------------------------------------------------------------
29
- // Load config
30
- // ---------------------------------------------------------------------------
28
+ function isPlainObject(value) {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
31
  /**
32
- * Attempt to load a turbine config file from the current directory.
33
- * Returns the config if found, or an empty object.
32
+ * True when `value` is a pure ESM/CJS-interop wrapper: an object whose only
33
+ * meaningful export is `default` (the `__esModule` marker is ignored). No
34
+ * Turbine config field is named `default`, so a real config never matches.
34
35
  */
35
- export async function loadConfig(cwd) {
36
+ function isPureDefaultWrapper(value) {
37
+ const keys = Object.keys(value).filter((k) => k !== '__esModule');
38
+ return keys.length === 1 && keys[0] === 'default';
39
+ }
40
+ /**
41
+ * Unwrap the module object returned by `import(configFile)` down to the actual
42
+ * config value.
43
+ *
44
+ * With `"type": "commonjs"` in the consumer's package.json (the `npm init -y`
45
+ * default) plus the tsx loader, importing `turbine.config.ts` yields a
46
+ * CJS-interop DOUBLE-wrapped default: `mod.default` is itself `{ default: config }`.
47
+ * A naive `mod.default ?? mod` then reads every field as `undefined`, so every
48
+ * command fails with a misleading "No database URL provided".
49
+ *
50
+ * This prefers `default` when present (the historical behavior) and then keeps
51
+ * descending through any additional pure `{ default: … }` wrappers, so both the
52
+ * correct single-default shape and the double-wrapped shape resolve to the same
53
+ * config. A genuine config object (which has real fields, never a lone
54
+ * `default`) is returned untouched.
55
+ */
56
+ export function unwrapModuleDefault(mod) {
57
+ // Step 1: prefer `default` at the top level (mirrors `mod.default ?? mod`).
58
+ let value = isPlainObject(mod) && mod.default != null ? mod.default : mod;
59
+ // Step 2: peel off any further pure interop wrappers, bounded to avoid a
60
+ // pathological self-referential object spinning forever.
61
+ for (let depth = 0; depth < 10 && isPlainObject(value) && isPureDefaultWrapper(value); depth++) {
62
+ value = value.default;
63
+ }
64
+ return value;
65
+ }
66
+ /**
67
+ * {@link unwrapModuleDefault} specialized for config files: a non-object export
68
+ * collapses to `{}` so downstream resolution falls through to env vars/flags.
69
+ */
70
+ export function unwrapConfigModule(mod) {
71
+ const value = unwrapModuleDefault(mod);
72
+ return isPlainObject(value) ? value : {};
73
+ }
74
+ /**
75
+ * Attempt to load a turbine config file from the given directory, returning the
76
+ * resolved config together with any load failure so the caller can surface it.
77
+ *
78
+ * Candidates are tried in {@link CONFIG_FILES} priority order. The first one
79
+ * that imports successfully wins. If a candidate exists but throws (syntax
80
+ * error, ESM/CJS interop failure, etc.) we remember the first such error and
81
+ * keep trying lower-priority candidates; if none load, the remembered error is
82
+ * returned in `loadError` while `config` stays `{}` so env/flag resolution can
83
+ * still proceed.
84
+ */
85
+ export async function loadConfigResult(cwd) {
36
86
  const dir = cwd ?? process.cwd();
87
+ let loadError;
37
88
  for (const filename of CONFIG_FILES) {
38
89
  const filePath = join(dir, filename);
39
90
  if (!existsSync(filePath))
@@ -41,21 +92,27 @@ export async function loadConfig(cwd) {
41
92
  try {
42
93
  const absPath = resolve(filePath);
43
94
  const fileUrl = pathToFileURL(absPath).href;
44
- // For .ts files, we need to rely on Node's --experimental-strip-types
45
- // or the tsx loader. Dynamic import handles .js/.mjs natively.
95
+ // For .ts files, we rely on the tsx loader being registered by the CLI
96
+ // before this runs. Dynamic import handles .js/.mjs natively.
46
97
  const mod = await import(fileUrl);
47
- const config = mod.default ?? mod;
48
- return config;
98
+ return { config: unwrapConfigModule(mod) };
49
99
  }
50
100
  catch (err) {
51
- // If importing a .ts file fails, try the next one
52
- if (filename.endsWith('.ts') || filename.endsWith('.mts')) {
53
- continue;
54
- }
55
- throw new Error(`Failed to load config from ${filename}: ${err instanceof Error ? err.message : String(err)}`);
101
+ // Remember the first real load failure but keep trying lower-priority
102
+ // candidates (e.g. a working .js next to a broken .ts).
103
+ if (!loadError)
104
+ loadError = { filename, error: err };
56
105
  }
57
106
  }
58
- return {};
107
+ return loadError ? { config: {}, loadError } : { config: {} };
108
+ }
109
+ /**
110
+ * Attempt to load a turbine config file from the current directory.
111
+ * Returns the config if found, or an empty object. Load failures are swallowed
112
+ * here; callers that need to surface them should use {@link loadConfigResult}.
113
+ */
114
+ export async function loadConfig(cwd) {
115
+ return (await loadConfigResult(cwd)).config;
59
116
  }
60
117
  /**
61
118
  * Find the config file path (for display purposes).
@@ -53,6 +53,74 @@ export interface CliArgs {
53
53
  allowRemote?: boolean;
54
54
  }
55
55
  export declare function parseArgs(argv?: string[]): CliArgs;
56
+ /** Where a resolved `DATABASE_URL` came from, after the `.env` load. */
57
+ export type DotEnvProvenance = 'shell' | 'dotenv' | 'none';
58
+ /** Structured outcome of {@link loadDotEnvForCli}. */
59
+ export interface DotEnvLoadResult {
60
+ /** A `.env` file was present in the working directory. */
61
+ fileExists: boolean;
62
+ /** The `.env` was actually read into the environment. */
63
+ loaded: boolean;
64
+ /** A `.env` exists but this runtime cannot auto-load it (Node < 20.12). */
65
+ unsupported: boolean;
66
+ /** Where `DATABASE_URL` ended up coming from once the load settled. */
67
+ databaseUrlProvenance: DotEnvProvenance;
68
+ /** Set when the loader threw (e.g. EACCES / a directory named `.env`). */
69
+ loadError?: string;
70
+ }
71
+ /**
72
+ * Load a local `.env` into `process.env` for the CLI, mirroring what
73
+ * `node --env-file=.env` does. Loaded UNCONDITIONALLY when a `.env` is present,
74
+ * so every variable it defines (not just `DATABASE_URL`) reaches the config
75
+ * file and user scripts.
76
+ *
77
+ * A pre-existing variable ALWAYS wins: `process.loadEnvFile()` never overrides
78
+ * an already-set variable, so a real shell/CI `DATABASE_URL` beats the file.
79
+ * Provenance is tracked so callers can warn when an `.env`-sourced
80
+ * `DATABASE_URL` silently overrides a differing `url` in `turbine.config.ts`:
81
+ * `DATABASE_URL` is `'dotenv'`-sourced only when it was absent before the load
82
+ * and present after.
83
+ *
84
+ * `process.loadEnvFile` is Node 20.12+. Turbine's engines allow `>=20.0.0`, so
85
+ * on older runtimes this no-ops with `unsupported: true` (never throws). A
86
+ * loader that throws (unreadable file, a directory named `.env`) is caught and
87
+ * surfaced as `loadError`, never a raw unhandled rejection. Deliberately
88
+ * CLI-only: the library must never read files.
89
+ *
90
+ * Dependencies are injectable purely so this is unit-testable without mutating
91
+ * the real process environment.
92
+ */
93
+ export declare function loadDotEnvForCli(deps?: {
94
+ env?: NodeJS.ProcessEnv;
95
+ cwd?: string;
96
+ fileExists?: (path: string) => boolean;
97
+ loadEnvFile?: ((path: string) => void) | null;
98
+ }): DotEnvLoadResult;
99
+ /**
100
+ * Decide whether to warn that an `.env`-sourced `DATABASE_URL` is overriding a
101
+ * differing, non-empty `url` in the config file. Pure so it is unit-testable.
102
+ *
103
+ * Precedence is unchanged (`.env` `DATABASE_URL` still wins), this only decides
104
+ * whether that override is silent or loud. We warn ONLY when all hold:
105
+ * - no CLI `--url` override (an explicit override is the user's clear intent),
106
+ * - `DATABASE_URL` came from `.env` (shell-exported stays silent, as before),
107
+ * - the config file has a non-empty `url`, and
108
+ * - the two URLs actually differ.
109
+ *
110
+ * Returns the warning message (URLs redacted), or `null` for no warning.
111
+ */
112
+ export declare function dotEnvUrlConflictWarning(input: {
113
+ provenance: DotEnvProvenance;
114
+ envUrl: string | undefined;
115
+ fileConfigUrl: string | undefined;
116
+ overrideUrl: string | undefined;
117
+ }): string | null;
118
+ /**
119
+ * Read the consumer's `package.json` `"type"` field. Returns `'module'` for an
120
+ * ESM project, `'commonjs'` for an explicit or absent (defaulted) CommonJS
121
+ * project, and `'none'` when there is no readable/parseable package.json.
122
+ */
123
+ export declare function detectConsumerModuleType(cwd?: string): 'module' | 'commonjs' | 'none';
56
124
  export declare function buildMigrateDeployOptions(_args: CliArgs): {
57
125
  allowDrift: false;
58
126
  allowDestructive: true;
package/dist/cli/index.js CHANGED
@@ -24,13 +24,13 @@
24
24
  * npx turbine migrate create add_users_table
25
25
  */
26
26
  import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
27
- import { basename, dirname, extname, relative, resolve } from 'node:path';
27
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
28
28
  import { pathToFileURL } from 'node:url';
29
29
  import { generate } from '../generate.js';
30
30
  import { findMissingRelationIndexes } from '../index-advisor.js';
31
31
  import { introspect } from '../introspect.js';
32
32
  import { schemaDiff, schemaPush } from '../schema-sql.js';
33
- import { configTemplate, findConfigFile, loadConfig, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, } from './config.js';
33
+ import { configTemplate, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
34
34
  import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
35
35
  import { runMcpServer } from './mcp.js';
36
36
  import { createMigration, inspectMigrationDeploy, listMigrationFiles, migrateDeploy, migrateDown, migrateStatus, migrateUp, } from './migrate.js';
@@ -191,7 +191,9 @@ function requireUrl(config) {
191
191
  newline();
192
192
  console.log(` ${dim('Set it in one of these ways:')}`);
193
193
  console.log(` ${dim('1.')} Add ${cyan('url')} to ${cyan('turbine.config.ts')}`);
194
- console.log(` ${dim('2.')} Set ${cyan('DATABASE_URL')} environment variable`);
194
+ // .env auto-load needs Node 20.12+ (process.loadEnvFile); be honest below it.
195
+ const envFileNote = typeof process.loadEnvFile === 'function' ? '(auto-loaded)' : '(needs Node 20.12+ to auto-load)';
196
+ console.log(` ${dim('2.')} Set ${cyan('DATABASE_URL')} in your environment or a ${cyan('.env')} file ${dim(envFileNote)}`);
195
197
  console.log(` ${dim('3.')} Pass ${cyan('--url')} flag`);
196
198
  newline();
197
199
  process.exit(1);
@@ -217,8 +219,12 @@ async function loadSchemaFile(schemaFile) {
217
219
  try {
218
220
  const fileUrl = pathToFileURL(absPath).href;
219
221
  const mod = await import(fileUrl);
220
- const schema = mod.default ?? mod;
221
- if (!schema.tables) {
222
+ // Unwrap the same CJS-interop double-wrapped default that bites config files
223
+ // in a "type": "commonjs" project under the tsx loader (see
224
+ // unwrapModuleDefault). Without this, `mod.default ?? mod` reads
225
+ // `{ default: schemaDef }` and `.tables` is undefined.
226
+ const schema = unwrapModuleDefault(mod);
227
+ if (!schema?.tables) {
222
228
  error('Schema file must export a SchemaDef with a "tables" property.');
223
229
  process.exit(1);
224
230
  }
@@ -255,29 +261,151 @@ function printCjsHintIfApplicable(err) {
255
261
  console.log(` ${dim('Turbine is an ESM package; without it, Node/tsx tries to')} ${cyan('require()')} ${dim('it and fails.')}`);
256
262
  }
257
263
  }
264
+ /**
265
+ * Load a local `.env` into `process.env` for the CLI, mirroring what
266
+ * `node --env-file=.env` does. Loaded UNCONDITIONALLY when a `.env` is present,
267
+ * so every variable it defines (not just `DATABASE_URL`) reaches the config
268
+ * file and user scripts.
269
+ *
270
+ * A pre-existing variable ALWAYS wins: `process.loadEnvFile()` never overrides
271
+ * an already-set variable, so a real shell/CI `DATABASE_URL` beats the file.
272
+ * Provenance is tracked so callers can warn when an `.env`-sourced
273
+ * `DATABASE_URL` silently overrides a differing `url` in `turbine.config.ts`:
274
+ * `DATABASE_URL` is `'dotenv'`-sourced only when it was absent before the load
275
+ * and present after.
276
+ *
277
+ * `process.loadEnvFile` is Node 20.12+. Turbine's engines allow `>=20.0.0`, so
278
+ * on older runtimes this no-ops with `unsupported: true` (never throws). A
279
+ * loader that throws (unreadable file, a directory named `.env`) is caught and
280
+ * surfaced as `loadError`, never a raw unhandled rejection. Deliberately
281
+ * CLI-only: the library must never read files.
282
+ *
283
+ * Dependencies are injectable purely so this is unit-testable without mutating
284
+ * the real process environment.
285
+ */
286
+ export function loadDotEnvForCli(deps = {}) {
287
+ const env = deps.env ?? process.env;
288
+ const cwd = deps.cwd ?? process.cwd();
289
+ const fileExists = deps.fileExists ?? existsSync;
290
+ const envPath = join(cwd, '.env');
291
+ const hadUrlBefore = Boolean(env.DATABASE_URL);
292
+ const shellOrNone = hadUrlBefore ? 'shell' : 'none';
293
+ if (!fileExists(envPath)) {
294
+ return { fileExists: false, loaded: false, unsupported: false, databaseUrlProvenance: shellOrNone };
295
+ }
296
+ const loader = deps.loadEnvFile !== undefined
297
+ ? deps.loadEnvFile
298
+ : typeof process.loadEnvFile === 'function'
299
+ ? process.loadEnvFile.bind(process)
300
+ : null;
301
+ if (!loader) {
302
+ return { fileExists: true, loaded: false, unsupported: true, databaseUrlProvenance: shellOrNone };
303
+ }
304
+ try {
305
+ loader(envPath);
306
+ }
307
+ catch (err) {
308
+ return {
309
+ fileExists: true,
310
+ loaded: false,
311
+ unsupported: false,
312
+ databaseUrlProvenance: shellOrNone,
313
+ loadError: err instanceof Error ? err.message : String(err),
314
+ };
315
+ }
316
+ // `.env`-sourced only if DATABASE_URL was absent before and present after.
317
+ const provenance = hadUrlBefore ? 'shell' : env.DATABASE_URL ? 'dotenv' : 'none';
318
+ return { fileExists: true, loaded: true, unsupported: false, databaseUrlProvenance: provenance };
319
+ }
320
+ /**
321
+ * Decide whether to warn that an `.env`-sourced `DATABASE_URL` is overriding a
322
+ * differing, non-empty `url` in the config file. Pure so it is unit-testable.
323
+ *
324
+ * Precedence is unchanged (`.env` `DATABASE_URL` still wins), this only decides
325
+ * whether that override is silent or loud. We warn ONLY when all hold:
326
+ * - no CLI `--url` override (an explicit override is the user's clear intent),
327
+ * - `DATABASE_URL` came from `.env` (shell-exported stays silent, as before),
328
+ * - the config file has a non-empty `url`, and
329
+ * - the two URLs actually differ.
330
+ *
331
+ * Returns the warning message (URLs redacted), or `null` for no warning.
332
+ */
333
+ export function dotEnvUrlConflictWarning(input) {
334
+ if (input.overrideUrl)
335
+ return null;
336
+ if (input.provenance !== 'dotenv')
337
+ return null;
338
+ const fileUrl = input.fileConfigUrl?.trim();
339
+ if (!fileUrl)
340
+ return null;
341
+ if (!input.envUrl)
342
+ return null;
343
+ if (fileUrl === input.envUrl)
344
+ return null;
345
+ return (`DATABASE_URL from .env (${redactUrl(input.envUrl)}) is overriding the url in your config file ` +
346
+ `(${redactUrl(fileUrl)}). Using the .env value. Remove DATABASE_URL from .env, or unset the config url, ` +
347
+ `to silence this.`);
348
+ }
349
+ /**
350
+ * Read the consumer's `package.json` `"type"` field. Returns `'module'` for an
351
+ * ESM project, `'commonjs'` for an explicit or absent (defaulted) CommonJS
352
+ * project, and `'none'` when there is no readable/parseable package.json.
353
+ */
354
+ export function detectConsumerModuleType(cwd = process.cwd()) {
355
+ const pkgPath = join(cwd, 'package.json');
356
+ if (!existsSync(pkgPath))
357
+ return 'none';
358
+ try {
359
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
360
+ return pkg.type === 'module' ? 'module' : 'commonjs';
361
+ }
362
+ catch {
363
+ return 'none';
364
+ }
365
+ }
258
366
  // ---------------------------------------------------------------------------
259
367
  // Command: init
260
368
  // ---------------------------------------------------------------------------
261
369
  async function cmdInit(args, config) {
262
370
  banner();
263
371
  header('Initializing Turbine project');
264
- // Detect environment
372
+ // Detect environment. main() has already auto-loaded a local `.env` into
373
+ // process.env (when DATABASE_URL was not otherwise set), so these messages
374
+ // describe the real, post-load state, no more "if set" hand-waving.
265
375
  const envUrl = process.env.DATABASE_URL;
266
376
  const hasEnvFile = existsSync('.env');
267
377
  const hasEnvLocal = existsSync('.env.local');
378
+ // On Node < 20.12 (no process.loadEnvFile) main() could not auto-load .env, so
379
+ // we cannot claim it "has no DATABASE_URL"; we simply could not read it.
380
+ const canAutoLoadEnv = typeof process.loadEnvFile === 'function';
268
381
  if (envUrl) {
269
- success(`Detected ${cyan('DATABASE_URL')} in environment`);
382
+ success(`Detected ${cyan('DATABASE_URL')} in the environment`);
270
383
  }
271
- else if (hasEnvLocal) {
272
- info(`Found ${cyan('.env.local')} Turbine will use ${cyan('DATABASE_URL')} from it if set`);
384
+ else if (hasEnvFile && !canAutoLoadEnv) {
385
+ info(`Found ${cyan('.env')} ${dim('(this Node version cannot auto-load it. Upgrade to Node 20.12+ or export')} ${cyan('DATABASE_URL')}${dim(')')}`);
273
386
  }
274
387
  else if (hasEnvFile) {
275
- info(`Found ${cyan('.env')} Turbine will use ${cyan('DATABASE_URL')} from it if set`);
388
+ // .env exists but did not provide DATABASE_URL; if it had, the auto-load
389
+ // in main() would have populated envUrl above.
390
+ info(`Found ${cyan('.env')} ${dim('(no')} ${cyan('DATABASE_URL')} ${dim('set in it yet)')}`);
391
+ }
392
+ else if (hasEnvLocal) {
393
+ info(`Found ${cyan('.env.local')} ${dim('(note: Turbine only auto-loads')} ${cyan('.env')}${dim(')')}`);
276
394
  }
277
395
  else {
278
396
  info(`No ${cyan('DATABASE_URL')} found in environment`);
279
397
  }
280
398
  newline();
399
+ // Heads-up (not an edit) about the consumer's module system. A CommonJS
400
+ // project (`npm init -y` default, or no "type" field) works fine now that the
401
+ // config loader unwraps the CJS-interop double-wrapped default, but ESM is the
402
+ // smoother path for a TypeScript config file.
403
+ const moduleType = detectConsumerModuleType();
404
+ if (moduleType === 'commonjs') {
405
+ info(`Your ${cyan('package.json')} is a CommonJS project ${dim('(no')} ${cyan('"type": "module"')}${dim(').')}`);
406
+ console.log(` ${dim('Turbine works either way. For the smoothest TypeScript config experience, consider adding')} ${cyan('"type": "module"')}${dim('.')}`);
407
+ newline();
408
+ }
281
409
  const configPath = findConfigFile();
282
410
  // Create config file
283
411
  if (configPath && !args.force) {
@@ -1687,6 +1815,18 @@ async function main() {
1687
1815
  showVersion();
1688
1816
  return;
1689
1817
  }
1818
+ // Load a local `.env` so `DATABASE_URL` (and every other var it defines) is
1819
+ // available to the config file, to `turbine()` in user scripts, and to command
1820
+ // resolution: exactly what the quickstart promises. A pre-existing env var
1821
+ // always wins. Surfaces the honest state when the file cannot be read.
1822
+ const dotEnv = loadDotEnvForCli();
1823
+ if (dotEnv.loadError) {
1824
+ warn(`Could not read ${cyan('.env')}: ${dotEnv.loadError}. Continuing without it.`);
1825
+ }
1826
+ else if (dotEnv.fileExists && dotEnv.unsupported) {
1827
+ warn(`Found ${cyan('.env')} but this Node version cannot auto-load it. ` +
1828
+ `Upgrade to Node 20.12+ or export ${cyan('DATABASE_URL')} yourself.`);
1829
+ }
1690
1830
  // If the user has a TypeScript config file, register the tsx ESM loader
1691
1831
  // before we attempt to import it. Otherwise Node throws
1692
1832
  // ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
@@ -1697,17 +1837,16 @@ async function main() {
1697
1837
  failMissingTsLoader(configPath ?? 'turbine.config.ts', status);
1698
1838
  }
1699
1839
  }
1700
- // Load config file
1701
- let fileConfig = {};
1702
- try {
1703
- fileConfig = await loadConfig();
1704
- }
1705
- catch (err) {
1706
- if (args.command !== 'init') {
1707
- warn(`Could not load config: ${err instanceof Error ? err.message : String(err)}`);
1708
- if (err instanceof Error)
1709
- printCjsHintIfApplicable(err);
1710
- }
1840
+ // Load config file. A config that exists but fails to import is surfaced
1841
+ // loudly (with a name + the underlying error) instead of being swallowed and
1842
+ // later misreported as a missing database URL.
1843
+ const { config: fileConfig, loadError } = await loadConfigResult();
1844
+ if (loadError && args.command !== 'init') {
1845
+ const underlying = loadError.error instanceof Error ? loadError.error.message : String(loadError.error);
1846
+ warn(`Could not load ${cyan(loadError.filename)}: ${underlying}`);
1847
+ if (loadError.error instanceof Error)
1848
+ printCjsHintIfApplicable(loadError.error);
1849
+ newline();
1711
1850
  }
1712
1851
  const overrides = {
1713
1852
  url: args.url,
@@ -1717,6 +1856,19 @@ async function main() {
1717
1856
  exclude: args.exclude,
1718
1857
  };
1719
1858
  const config = resolveConfig(fileConfig, overrides);
1859
+ // Warn (don't change precedence) when an .env-sourced DATABASE_URL is silently
1860
+ // overriding a differing, non-empty url in the config file (a wrong-database
1861
+ // hazard for push/migrate/seed). Shell-exported DATABASE_URL stays silent.
1862
+ const urlConflict = dotEnvUrlConflictWarning({
1863
+ provenance: dotEnv.databaseUrlProvenance,
1864
+ envUrl: process.env.DATABASE_URL,
1865
+ fileConfigUrl: fileConfig.url,
1866
+ overrideUrl: overrides.url,
1867
+ });
1868
+ if (urlConflict && args.command !== 'init') {
1869
+ warn(urlConflict);
1870
+ newline();
1871
+ }
1720
1872
  try {
1721
1873
  switch (args.command) {
1722
1874
  case 'init':
package/dist/client.js CHANGED
@@ -386,9 +386,25 @@ export class TurbineClient {
386
386
  idleTimeoutMillis: config.idleTimeoutMs ?? 30_000,
387
387
  connectionTimeoutMillis: config.connectionTimeoutMs ?? 5_000,
388
388
  };
389
+ // Did the caller supply ANY explicit connection target? If not, and a
390
+ // DATABASE_URL is present in the environment, fall back to it so
391
+ // `turbine()` with no arguments just works (the convention Prisma/Drizzle
392
+ // use, and what the generated factory JSDoc + `turbine init` promise).
393
+ // We only read the already-populated env var; the library never parses
394
+ // .env files (that is the CLI's job). An explicit host/port/db/user/pass
395
+ // still takes precedence, so this never overrides a deliberate config.
396
+ const hasExplicitConnection = config.connectionString != null ||
397
+ config.host != null ||
398
+ config.port != null ||
399
+ config.database != null ||
400
+ config.user != null ||
401
+ config.password != null;
389
402
  if (config.connectionString) {
390
403
  poolConfig.connectionString = config.connectionString;
391
404
  }
405
+ else if (!hasExplicitConnection && process.env.DATABASE_URL) {
406
+ poolConfig.connectionString = process.env.DATABASE_URL;
407
+ }
392
408
  else {
393
409
  poolConfig.host = config.host ?? 'localhost';
394
410
  poolConfig.port = config.port ?? 5432;
package/dist/errors.d.ts CHANGED
@@ -91,7 +91,18 @@ export declare class NotFoundError extends TurbineError {
91
91
  /** Thrown when a query or transaction exceeds the configured timeout */
92
92
  export declare class TimeoutError extends TurbineError {
93
93
  readonly timeoutMs: number;
94
- constructor(timeoutMs: number, context?: string);
94
+ /**
95
+ * @param timeoutMs the client-side timeout budget in ms. Pass `0` when the
96
+ * duration is unknown (e.g. a server-side `statement_timeout` cancellation
97
+ * surfaced via `wrapPgError`, where Turbine did not set the deadline).
98
+ * @param context human label for the operation ("Query", "Transaction").
99
+ * @param options optional `message` override and pg `cause` to preserve, used
100
+ * when wrapping a driver error rather than a client-side timer expiry.
101
+ */
102
+ constructor(timeoutMs: number, context?: string, options?: {
103
+ message?: string;
104
+ cause?: unknown;
105
+ });
95
106
  }
96
107
  /** Thrown when query arguments fail validation (unknown column, invalid operator, etc.) */
97
108
  export declare class ValidationError extends TurbineError {
@@ -99,7 +110,14 @@ export declare class ValidationError extends TurbineError {
99
110
  }
100
111
  /** Thrown when a database connection fails */
101
112
  export declare class ConnectionError extends TurbineError {
102
- constructor(message: string);
113
+ /**
114
+ * @param message human-readable connection failure description.
115
+ * @param options optional pg/driver `cause` to preserve, used when wrapping a
116
+ * connection-class driver error via `wrapPgError`.
117
+ */
118
+ constructor(message: string, options?: {
119
+ cause?: unknown;
120
+ });
103
121
  }
104
122
  /** Thrown when a relation reference is invalid */
105
123
  export declare class RelationError extends TurbineError {
@@ -297,6 +315,8 @@ export declare class UnsupportedFeatureError extends TurbineError {
297
315
  * 23P01 (exclusion_violation) -> ExclusionConstraintError
298
316
  * 40P01 (deadlock_detected) -> DeadlockError (retryable)
299
317
  * 40001 (serialization_failure) -> SerializationFailureError (retryable)
318
+ * 57014 (query_canceled) -> TimeoutError (server-side statement_timeout)
319
+ * connection-class codes -> ConnectionError (see CONNECTION_ERROR_CODES)
300
320
  *
301
321
  * The original pg error is preserved as `.cause` on the wrapped error.
302
322
  */