turbine-orm 0.32.0 → 0.32.1
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 +28 -19
- package/dist/cjs/cli/config.js +76 -16
- package/dist/cjs/cli/index.js +174 -19
- package/dist/cjs/client.js +16 -0
- package/dist/cjs/errors.js +62 -4
- package/dist/cjs/generate.js +2 -1
- package/dist/cjs/powql.js +1 -1
- package/dist/cjs/query/builder.js +235 -41
- package/dist/cli/config.d.ts +53 -1
- package/dist/cli/config.js +73 -16
- package/dist/cli/index.d.ts +68 -0
- package/dist/cli/index.js +173 -21
- package/dist/client.js +16 -0
- package/dist/errors.d.ts +22 -2
- package/dist/errors.js +62 -4
- package/dist/generate.js +2 -1
- package/dist/powql.js +1 -1
- package/dist/query/builder.d.ts +43 -0
- package/dist/query/builder.js +235 -41
- package/dist/query/deferred.d.ts +10 -1
- package/package.json +3 -2
package/dist/cli/index.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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 (
|
|
272
|
-
info(`Found ${cyan('.env
|
|
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
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/dist/errors.js
CHANGED
|
@@ -144,8 +144,16 @@ export class NotFoundError extends TurbineError {
|
|
|
144
144
|
/** Thrown when a query or transaction exceeds the configured timeout */
|
|
145
145
|
export class TimeoutError extends TurbineError {
|
|
146
146
|
timeoutMs;
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
/**
|
|
148
|
+
* @param timeoutMs the client-side timeout budget in ms. Pass `0` when the
|
|
149
|
+
* duration is unknown (e.g. a server-side `statement_timeout` cancellation
|
|
150
|
+
* surfaced via `wrapPgError`, where Turbine did not set the deadline).
|
|
151
|
+
* @param context human label for the operation ("Query", "Transaction").
|
|
152
|
+
* @param options optional `message` override and pg `cause` to preserve, used
|
|
153
|
+
* when wrapping a driver error rather than a client-side timer expiry.
|
|
154
|
+
*/
|
|
155
|
+
constructor(timeoutMs, context = 'Query', options) {
|
|
156
|
+
super(TurbineErrorCode.TIMEOUT, options?.message ?? `[turbine] ${context} timed out after ${timeoutMs}ms`, options);
|
|
149
157
|
this.name = 'TimeoutError';
|
|
150
158
|
this.timeoutMs = timeoutMs;
|
|
151
159
|
}
|
|
@@ -159,8 +167,13 @@ export class ValidationError extends TurbineError {
|
|
|
159
167
|
}
|
|
160
168
|
/** Thrown when a database connection fails */
|
|
161
169
|
export class ConnectionError extends TurbineError {
|
|
162
|
-
|
|
163
|
-
|
|
170
|
+
/**
|
|
171
|
+
* @param message human-readable connection failure description.
|
|
172
|
+
* @param options optional pg/driver `cause` to preserve, used when wrapping a
|
|
173
|
+
* connection-class driver error via `wrapPgError`.
|
|
174
|
+
*/
|
|
175
|
+
constructor(message, options) {
|
|
176
|
+
super(TurbineErrorCode.CONNECTION, message, options);
|
|
164
177
|
this.name = 'ConnectionError';
|
|
165
178
|
}
|
|
166
179
|
}
|
|
@@ -477,6 +490,35 @@ function parseColumnsFromDetail(detail) {
|
|
|
477
490
|
return undefined;
|
|
478
491
|
return m[1].split(',').map((s) => s.trim());
|
|
479
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* Connection-class error codes. Covers both pg SQLSTATEs (class 08
|
|
495
|
+
* connection_exception, plus a few class-53/57 admin/availability codes) and
|
|
496
|
+
* Node driver-level error codes that arrive on the same `.code` field when the
|
|
497
|
+
* socket never reaches Postgres. All map to {@link ConnectionError} (E004).
|
|
498
|
+
*
|
|
499
|
+
* `57014` (query_canceled, a server-side `statement_timeout` cancellation) is
|
|
500
|
+
* intentionally NOT here: it maps to {@link TimeoutError} (E002) instead.
|
|
501
|
+
*/
|
|
502
|
+
const CONNECTION_ERROR_CODES = new Set([
|
|
503
|
+
// pg SQLSTATE class 08: connection_exception
|
|
504
|
+
'08000', // connection_exception
|
|
505
|
+
'08001', // sqlclient_unable_to_establish_sqlconnection
|
|
506
|
+
'08003', // connection_does_not_exist
|
|
507
|
+
'08004', // sqlserver_rejected_establishment_of_sqlconnection
|
|
508
|
+
'08006', // connection_failure
|
|
509
|
+
'08P01', // protocol_violation
|
|
510
|
+
// pg SQLSTATE class 53/57 (server unavailable / shutting down)
|
|
511
|
+
'53300', // too_many_connections
|
|
512
|
+
'57P01', // admin_shutdown
|
|
513
|
+
'57P02', // crash_shutdown
|
|
514
|
+
'57P03', // cannot_connect_now
|
|
515
|
+
// Node driver-level socket errors (surface on err.code too)
|
|
516
|
+
'ECONNREFUSED',
|
|
517
|
+
'ECONNRESET',
|
|
518
|
+
'ETIMEDOUT',
|
|
519
|
+
'ENOTFOUND',
|
|
520
|
+
'EPIPE',
|
|
521
|
+
]);
|
|
480
522
|
/**
|
|
481
523
|
* Translate a pg driver error into a typed Turbine error.
|
|
482
524
|
* If the error doesn't match a known constraint code, returns it unchanged.
|
|
@@ -489,6 +531,8 @@ function parseColumnsFromDetail(detail) {
|
|
|
489
531
|
* 23P01 (exclusion_violation) -> ExclusionConstraintError
|
|
490
532
|
* 40P01 (deadlock_detected) -> DeadlockError (retryable)
|
|
491
533
|
* 40001 (serialization_failure) -> SerializationFailureError (retryable)
|
|
534
|
+
* 57014 (query_canceled) -> TimeoutError (server-side statement_timeout)
|
|
535
|
+
* connection-class codes -> ConnectionError (see CONNECTION_ERROR_CODES)
|
|
492
536
|
*
|
|
493
537
|
* The original pg error is preserved as `.cause` on the wrapped error.
|
|
494
538
|
*/
|
|
@@ -541,7 +585,21 @@ export function wrapPgError(err) {
|
|
|
541
585
|
return new SerializationFailureError({
|
|
542
586
|
cause: err,
|
|
543
587
|
});
|
|
588
|
+
case '57014':
|
|
589
|
+
// query_canceled: a server-side statement_timeout cancelled the query.
|
|
590
|
+
// Turbine did not set the deadline (that lives in Postgres config), so
|
|
591
|
+
// there is no client-side budget to report → timeoutMs = 0.
|
|
592
|
+
return new TimeoutError(0, 'Query', {
|
|
593
|
+
message: '[turbine] Query canceled by server-side statement_timeout',
|
|
594
|
+
cause: err,
|
|
595
|
+
});
|
|
544
596
|
default:
|
|
597
|
+
if (CONNECTION_ERROR_CODES.has(e.code)) {
|
|
598
|
+
const pgMessage = typeof e.message === 'string' && e.message.length > 0 ? e.message : undefined;
|
|
599
|
+
return new ConnectionError(pgMessage
|
|
600
|
+
? `[turbine] Database connection error: ${pgMessage}`
|
|
601
|
+
: `[turbine] Database connection error (${e.code})`, { cause: err });
|
|
602
|
+
}
|
|
545
603
|
return err;
|
|
546
604
|
}
|
|
547
605
|
}
|
package/dist/generate.js
CHANGED
|
@@ -670,7 +670,8 @@ export function generateIndex(schema, options) {
|
|
|
670
670
|
lines.push('/**');
|
|
671
671
|
lines.push(' * Create a new Turbine client instance.');
|
|
672
672
|
lines.push(' *');
|
|
673
|
-
lines.push(' * @param config - Connection configuration.
|
|
673
|
+
lines.push(' * @param config - Connection configuration. Omit it (or pass no connection');
|
|
674
|
+
lines.push(' * fields) to fall back to the `DATABASE_URL` environment variable.');
|
|
674
675
|
lines.push(' * @returns A fully-typed TurbineClient with table accessors.');
|
|
675
676
|
lines.push(' */');
|
|
676
677
|
lines.push('export function turbine(config?: TurbineConfig): TurbineClient {');
|
package/dist/powql.js
CHANGED
|
@@ -582,7 +582,7 @@ export class PowqlInterface {
|
|
|
582
582
|
const limit = args.limit ?? args.take ?? this.defaultLimit;
|
|
583
583
|
if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
|
|
584
584
|
this.warnedUnlimited = true;
|
|
585
|
-
console.warn(`[turbine] findMany on "${this.table}" has no limit
|
|
585
|
+
console.warn(`[turbine] findMany on "${this.table}" has no limit: this scans the whole table.`);
|
|
586
586
|
}
|
|
587
587
|
const limitClause = limit !== undefined ? ` limit ${this.param(limit, params)}` : '';
|
|
588
588
|
const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
|
package/dist/query/builder.d.ts
CHANGED
|
@@ -22,6 +22,15 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
22
22
|
private readonly tableMeta;
|
|
23
23
|
/** SQL template cache: cacheKey → SqlCacheEntry (sql + prepared statement name) */
|
|
24
24
|
private readonly sqlTemplateCache;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the most recent {@link acquireSql} call was a cache HIT. Read by
|
|
27
|
+
* {@link crossCheckCache} to decide whether to run the dev-mode lockstep
|
|
28
|
+
* cross-check. Safe as a single mutable flag: each `build*()` method calls
|
|
29
|
+
* `acquireSql` then `crossCheckCache` synchronously with no intervening
|
|
30
|
+
* `await` and no re-entrant `acquireSql` (relation subqueries are built
|
|
31
|
+
* inline, not through the top-level cache).
|
|
32
|
+
*/
|
|
33
|
+
private lastCacheHit;
|
|
25
34
|
private readonly middlewares;
|
|
26
35
|
private readonly defaultLimit?;
|
|
27
36
|
private readonly warnOnUnlimited;
|
|
@@ -183,8 +192,42 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
183
192
|
* On hit, increments counters and returns the cached entry.
|
|
184
193
|
*
|
|
185
194
|
* When `sqlCache` is disabled, always calls `build()` without caching.
|
|
195
|
+
*
|
|
196
|
+
* `build` receives a fresh `$N` param scratch array. On a miss those params
|
|
197
|
+
* are discarded (the returned params come from each call site's dedicated
|
|
198
|
+
* collect path); the array exists so the build path can number placeholders
|
|
199
|
+
* via `params.length` exactly as it does today. On a HIT, `build` is skipped
|
|
200
|
+
* here but re-run by {@link crossCheckCache} (dev only) with a fresh array to
|
|
201
|
+
* verify the collect path stayed in lockstep with the build path.
|
|
202
|
+
*
|
|
203
|
+
* Sets {@link lastCacheHit} so the caller's `crossCheckCache` knows whether a
|
|
204
|
+
* cross-check is warranted.
|
|
186
205
|
*/
|
|
187
206
|
private acquireSql;
|
|
207
|
+
/**
|
|
208
|
+
* Dev-mode SQL-cache lockstep cross-check (see {@link cacheCrossCheckEnabled}).
|
|
209
|
+
*
|
|
210
|
+
* Runs only when the most recent {@link acquireSql} was a cache HIT and the
|
|
211
|
+
* check is enabled. Rebuilds the SQL + `$N` params fresh via the same `build`
|
|
212
|
+
* closure the caller passed to `acquireSql`, then compares:
|
|
213
|
+
* (a) the cached SQL string byte-for-byte against the fresh SQL, and
|
|
214
|
+
* (b) the params the cache-hit collect path produced against the fresh
|
|
215
|
+
* build-path params (length and element-wise strict deep-equal).
|
|
216
|
+
*
|
|
217
|
+
* A mismatch means the fingerprint / build / collect paths have drifted out
|
|
218
|
+
* of lockstep (the exact class of bug that has silently corrupted results
|
|
219
|
+
* before), so it throws a {@link ValidationError} (E003) naming the
|
|
220
|
+
* fingerprint, the operation, and both SQL strings (truncated). Failing loud
|
|
221
|
+
* in dev/test is the point. Production never reaches the comparison.
|
|
222
|
+
*
|
|
223
|
+
* @param op human label of the calling build method (for the error message).
|
|
224
|
+
* @param cacheKey the cache fingerprint that HIT.
|
|
225
|
+
* @param entry the cached SQL entry that will be executed.
|
|
226
|
+
* @param build the same closure passed to `acquireSql`; re-run here to
|
|
227
|
+
* capture the fresh build-path SQL + params.
|
|
228
|
+
* @param collectedParams the params the caller's collect path produced.
|
|
229
|
+
*/
|
|
230
|
+
private crossCheckCache;
|
|
188
231
|
/**
|
|
189
232
|
* Reset the per-instance unlimited-query warning dedupe set.
|
|
190
233
|
* Exposed for tests so a single test process can verify the warning fires
|