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 +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 +12 -1
- package/dist/cjs/query/builder.js +332 -44
- 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 +12 -1
- package/dist/query/builder.d.ts +57 -0
- package/dist/query/builder.js +332 -44
- package/dist/query/deferred.d.ts +10 -1
- package/dist/query/types.d.ts +40 -2
- package/package.json +3 -2
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 **
|
|
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
|
|
33
|
-
| findMany
|
|
34
|
-
| findMany
|
|
35
|
-
| findUnique
|
|
36
|
-
| findUnique
|
|
37
|
-
| count
|
|
38
|
-
| stream
|
|
39
|
-
| atomic increment
|
|
40
|
-
| pipeline
|
|
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
|
-
**
|
|
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
|
-
- **
|
|
45
|
-
- **
|
|
46
|
-
- **
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 '
|
|
74
|
+
import { turbine } from 'turbine-orm';
|
|
74
75
|
|
|
75
76
|
// CommonJS
|
|
76
|
-
const { turbine } = require('
|
|
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:
|
package/dist/cjs/cli/config.js
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
+
function isPlainObject(value) {
|
|
73
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
74
|
+
}
|
|
72
75
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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
|
|
86
|
-
//
|
|
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
|
-
|
|
89
|
-
return config;
|
|
142
|
+
return { config: unwrapConfigModule(mod) };
|
|
90
143
|
}
|
|
91
144
|
catch (err) {
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
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).
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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 (
|
|
311
|
-
(0, ui_js_1.info)(`Found ${(0, ui_js_1.cyan)('.env
|
|
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
|
-
|
|
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
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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':
|
package/dist/cjs/client.js
CHANGED
|
@@ -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;
|
package/dist/cjs/errors.js
CHANGED
|
@@ -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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
}
|
package/dist/cjs/generate.js
CHANGED
|
@@ -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.
|
|
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 {');
|