turbine-orm 0.60.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,11 +22,11 @@ The reason to reach for Turbine is that every layer between you and a production
22
22
  4. **Data-destroying statements need consent.** `migrate up`, `migrate down` and `push` scan for `DROP TABLE`, `DROP COLUMN`, `TRUNCATE`, unqualified `DELETE` / `UPDATE`, and `ALTER COLUMN … TYPE`, print an itemized report, and refuse to run. Interactively you type `destroy my data` and then `yes`; in CI you pass `--allow-destructive`. A refused batch applies nothing.
23
23
  5. **The review a DBA would have given you, offline.** `npx turbine doctor` derives every column set the ORM's relation subqueries probe and reports the ones with no covering index, with a cost tier per finding. `--fix` writes the migration. No cloud service, no telemetry, no account: it reads your introspected schema.
24
24
 
25
- **On "only".** Each of these is checkable, so here is the checkable version, current as of July 2026: no other TypeScript ORM ships a studio that is read-only by default or that redacts PII (Prisma Studio is proprietary and its read-only request has been open since February 2021; Drizzle Studio is not open source and self-hosting runs through the paid Drizzle Gateway; TypeORM, MikroORM, Kysely and Sequelize have no studio). No TypeScript ORM CLI offers missing-index advice: not the Prisma CLI, drizzle-kit, kysely-ctl, or the MikroORM / TypeORM / Sequelize CLIs, and Prisma Optimize was retired in March 2026 in favour of cloud-only Query Insights. Prior art exists outside TypeScript, notably Ruby's `active_record_doctor`, so the honest claim is "no TypeScript ORM", not "no ORM". Turbine is the only TypeScript ORM that ships all five of the above together.
25
+ **On "only".** Each of these is checkable, so here is the checkable version, current as of July 2026: no other TypeScript ORM ships a studio that is read-only by default or that redacts PII (Prisma Studio is open source, `@prisma/studio-core` is Apache-2.0, but it has no read-only mode and that request has been open since February 2021; Drizzle Studio is not open source and self-hosting runs through the paid Drizzle Gateway; TypeORM, MikroORM, Kysely and Sequelize have no studio). No TypeScript ORM CLI offers missing-index advice: not the Prisma CLI, drizzle-kit, kysely-ctl, or the MikroORM / TypeORM / Sequelize CLIs, and Prisma Optimize was retired in March 2026 in favour of cloud-only Query Insights. Prior art exists outside TypeScript, notably Ruby's `active_record_doctor`, so the honest claim is "no TypeScript ORM", not "no ORM". Turbine is the only TypeScript ORM that ships all five of the above together.
26
26
 
27
27
  Two more things worth knowing, which are about cost rather than safety:
28
28
 
29
- - **One runtime dependency (`pg`).** No engine binary, no WASM, no adapter packages in lockstep. The main entry's **import graph** is ~65 kB brotli (edge ~50 kB) with `pg` external (measured 2026-07-25 on 0.50.0: 65.16 kB and 50.18 kB, budgeted in `.size-limit.js`). That is the client footprint your bundler sees, not the size of the dual ESM+CJS build on disk, which is larger. Prisma 7 dropped its Rust query engine but its client still ships a TypeScript/WASM query compiler, a ~1.6 MB bundle, down from the ~14 MB Rust-era client.
29
+ - **One runtime dependency (`pg`).** No engine binary, no WASM, no adapter packages in lockstep. The main entry's **import graph** is held under **77 kB brotli** (edge under 61 kB) with `pg` external. That is the ceiling `size-limit` enforces in CI from `.size-limit.js`, not a figure typed into this file: a measurement quoted in prose goes stale silently, and this one did, drifting ~12% low over ten releases before a review caught it. Run `npm run size` for the current number. That is the client footprint your bundler sees, not the size of the dual ESM+CJS build on disk, which is larger. Prisma 7 dropped its Rust query engine but its client still ships a TypeScript/WASM query compiler, a ~1.6 MB bundle, down from the ~14 MB Rust-era client.
30
30
  - **Real pipelining, not a batch transaction.** `db.pipeline(...)` uses the Postgres extended-query protocol (Parse/Bind/Execute/Sync) to put N independent queries in one TCP flush. node-postgres does not expose pipelining in its pure-JS core ([brianc/node-postgres#2646](https://github.com/brianc/node-postgres/issues/2646) was still open as of July 2026), and Drizzle's `db.batch()` is an implicit transaction on specific drivers rather than independent-query pipelining.
31
31
 
32
32
  **Beyond the safety bundle, what ships today:** [global filters](https://turbineorm.dev/global-filters) for soft-delete and multi-tenancy · [read replicas](https://turbineorm.dev/read-replicas) with a `$primary()` escape hatch · a read-only [MCP server](https://turbineorm.dev/mcp) for AI agents · [seed-as-code](https://turbineorm.dev/seeding) and a non-interactive `migrate deploy` for CI · [Zod generation](https://turbineorm.dev/zod) · read-only [views & generated columns](https://turbineorm.dev/views) · [optional SQLite / MySQL / SQL Server / PowDB engines](https://turbineorm.dev/engines) behind subpath exports · a [Prisma migration toolkit](https://turbineorm.dev/migrate-from-prisma) (schema mapper plus a runtime compat adapter) · a cost-aware index advisor in [`turbine doctor`](https://turbineorm.dev/cli#turbine-doctor).
@@ -1131,14 +1131,14 @@ Turbine maps Postgres types to TypeScript:
1131
1131
  |---|---|---|---|---|
1132
1132
  | **Engine / runtime** | No engine binary (`pg` only) | Client + TS/WASM query compiler | No engine | No engine |
1133
1133
  | **Runtime deps** | 1 (`pg`) | `@prisma/client` + required driver adapter | 0 | 0 |
1134
- | **Main bundle (brotli)** | ~65 kB import graph, `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
1134
+ | **Main bundle (brotli)** | under 77 kB import graph (CI-enforced), `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
1135
1135
  | **Studio** | Read-only, 192-bit auth | Full CRUD, cloud-hosted | Free; hosted Gateway paid | None |
1136
1136
  | **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
1137
1137
  | **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
1138
- | **Edge runtime** | One import swap, ~50 kB brotli | Driver adapter + WASM compiler | Native | Native |
1138
+ | **Edge runtime** | One import swap, under 61 kB brotli (CI-enforced) | Driver adapter + WASM compiler | Native | Native |
1139
1139
  | **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
1140
1140
  | **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
1141
- | **Nested relations** | 1 query, deep type inference | 1 query, shallow inference | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
1141
+ | **Nested relations** | 1 query, deep type inference | 1 query per relation by default; single-query `relationJoins` is still Preview and whole-query only | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
1142
1142
  | **Many-to-many** | Auto-detected from junctions | Implicit/explicit | Explicit `relations()` | Manual joins |
1143
1143
  | **Vector search** | Built-in `distance` / KNN | Preview / raw | Extension API | Manual |
1144
1144
  | **LISTEN/NOTIFY** | `$listen` / `$notify` | None | None | None |
@@ -1148,7 +1148,7 @@ Reading the table: no engine binary and no WASM, just one runtime dependency (`p
1148
1148
 
1149
1149
  *Competitor columns are re-verified against competitor releases on a fixed schedule. Last checked July 2026, against Prisma 7 and Drizzle 0.45. Features marked Preview or beta may change, and bundle sizes move release to release.*
1150
1150
 
1151
- **A note on Kysely.** Kysely's [`jsonArrayFrom` / `jsonObjectFrom`](https://kysely.dev/docs/recipes/relations) relations recipe builds nested results with the same correlated-subquery-plus-JSON approach Turbine uses, good evidence the pattern is the right one. The gap is in what the driver can no longer see once rows are aggregated into JSON: nested fields lose their column types, so a `Date` inside a `jsonArrayFrom` result is typed `Date` but arrives as a **string** at runtime ([kysely-org/kysely#482](https://github.com/kysely-org/kysely/issues/482)), and the nesting isn't type-checked at depth. Turbine's `WithResult` inference types the whole tree, and `parseNestedRow` re-applies date coercion (and snake→camel mapping) to every nested row, so `users[0].posts[0].createdAt` is an actual `Date`, at any depth, with no plugin to wire up.
1151
+ **A note on Kysely.** Kysely's [`jsonArrayFrom` / `jsonObjectFrom`](https://kysely.dev/docs/recipes/relations) relations recipe builds nested results with the same correlated-subquery-plus-JSON approach Turbine uses, good evidence the pattern is the right one. The gap is in what the driver can no longer see once rows are aggregated into JSON: a `Date` nested inside a `jsonArrayFrom` result arrives as a **string** at runtime, because the aggregation erased the column type the driver would otherwise have parsed on. Kysely types that honestly and leaves the conversion to you. Turbine's `parseNestedRow` re-applies date coercion and snake→camel mapping to every nested row, so `users[0].posts[0].createdAt` is an actual `Date` at any depth with nothing to wire up. Stated from documented behaviour rather than from an issue-tracker link: a linked issue's state changes without notice, and a claim resting on one goes stale silently.
1152
1152
 
1153
1153
  ## Limitations
1154
1154
 
@@ -1777,6 +1777,41 @@ async function cmdMigrateCreate(args, config) {
1777
1777
  console.log(` ${(0, ui_js_1.cyan)('npx turbine migrate up')}`);
1778
1778
  (0, ui_js_1.newline)();
1779
1779
  }
1780
+ /**
1781
+ * Print the SQL a `migrate up` / `migrate down` WOULD run, and execute nothing.
1782
+ *
1783
+ * `--dry-run` was parsed and then read by `push` and `deploy` only, so on
1784
+ * `migrate up` it was silently inert: the flag was accepted, no warning was
1785
+ * printed, and the migrations were applied. A flag whose whole purpose is
1786
+ * "show me what this would do to the database" that instead does it, against
1787
+ * whatever `--url` was passed, is the most dangerous shape a CLI bug can take.
1788
+ *
1789
+ * Reads through the same `parseMigrationContent` the executor uses, so what is
1790
+ * printed is what would run, rather than a second rendering that can drift.
1791
+ */
1792
+ function printMigrationDryRun(files, section) {
1793
+ const verb = section === 'up' ? 'apply' : 'roll back';
1794
+ if (files.length === 0) {
1795
+ (0, ui_js_1.info)(`Dry run: nothing to ${verb}.`);
1796
+ (0, ui_js_1.newline)();
1797
+ return;
1798
+ }
1799
+ (0, ui_js_1.info)(`Dry run: would ${verb} ${(0, ui_js_1.bold)(String(files.length))} migration(s). Nothing was executed.`);
1800
+ (0, ui_js_1.newline)();
1801
+ for (const file of files) {
1802
+ const parsed = (0, migrate_js_1.parseMigrationContent)((0, node_fs_1.readFileSync)(file.path, 'utf-8'));
1803
+ const sql = section === 'up' ? parsed.up : parsed.down;
1804
+ console.log(` ${(0, ui_js_1.cyan)(file.filename)}${parsed.noTransaction ? (0, ui_js_1.dim)(' (no transaction)') : ''}`);
1805
+ if (sql.trim().length === 0) {
1806
+ console.log(` ${(0, ui_js_1.dim)(`(empty ${section.toUpperCase()} section)`)}`);
1807
+ }
1808
+ else {
1809
+ for (const line of sql.split('\n'))
1810
+ console.log(` ${(0, ui_js_1.dim)(line)}`);
1811
+ }
1812
+ (0, ui_js_1.newline)();
1813
+ }
1814
+ }
1780
1815
  async function cmdMigrateUp(args, config) {
1781
1816
  (0, ui_js_1.banner)();
1782
1817
  const url = requireUrl(config);
@@ -1790,6 +1825,14 @@ async function cmdMigrateUp(args, config) {
1790
1825
  (0, ui_js_1.newline)();
1791
1826
  return;
1792
1827
  }
1828
+ if (args.dryRun) {
1829
+ const status = await (0, migrate_js_1.migrateStatus)(url, config.migrationsDir);
1830
+ let pending = status.filter((st) => !st.applied).map((st) => st.file);
1831
+ if (args.step != null && args.step > 0)
1832
+ pending = pending.slice(0, args.step);
1833
+ printMigrationDryRun(pending, 'up');
1834
+ return;
1835
+ }
1793
1836
  // Big, loud warning when bypassing drift detection, this is a deliberately
1794
1837
  // dangerous operation and the user should see it on every invocation.
1795
1838
  if (args.allowDrift) {
@@ -2011,6 +2054,14 @@ async function cmdMigrateDown(args, config) {
2011
2054
  (0, ui_js_1.label)('Database', (0, ui_js_1.redactUrl)(url));
2012
2055
  (0, ui_js_1.label)('Migrations', config.migrationsDir);
2013
2056
  (0, ui_js_1.newline)();
2057
+ if (args.dryRun) {
2058
+ const status = await (0, migrate_js_1.migrateStatus)(url, config.migrationsDir);
2059
+ // Rollback order is newest-applied first, and a migration whose file is
2060
+ // missing has no DOWN section to show.
2061
+ const applied = status.filter((st) => st.applied && !st.missingFile).map((st) => st.file);
2062
+ printMigrationDryRun(applied.reverse().slice(0, args.step ?? 1), 'down');
2063
+ return;
2064
+ }
2014
2065
  const spinner = new ui_js_1.Spinner('Rolling back migration(s)').start();
2015
2066
  let result;
2016
2067
  try {
@@ -193,13 +193,65 @@ function warnUnknownConfigKeys(config) {
193
193
  // must never be the reason a client fails to construct.
194
194
  }
195
195
  }
196
- /** Maps isolation level names to SQL */
197
- const ISOLATION_LEVELS = {
196
+ /**
197
+ * Maps isolation level names to SQL. Null-prototype and read through
198
+ * {@link resolveIsolationLevel}, never indexed directly: an inherited key
199
+ * (`constructor`, `toString`) would otherwise resolve to a function and be
200
+ * interpolated verbatim into `BEGIN ISOLATION LEVEL …`.
201
+ */
202
+ const ISOLATION_LEVELS = Object.assign(Object.create(null), {
198
203
  ReadUncommitted: 'READ UNCOMMITTED',
199
204
  ReadCommitted: 'READ COMMITTED',
200
205
  RepeatableRead: 'REPEATABLE READ',
201
206
  Serializable: 'SERIALIZABLE',
202
- };
207
+ });
208
+ /**
209
+ * Resolve a requested isolation level, REFUSING anything not in the map.
210
+ *
211
+ * A miss used to yield `undefined`, which `beginStatement` renders as a plain
212
+ * `BEGIN`. So `isolationLevel: 'serializable'` (wrong case) asked for
213
+ * SERIALIZABLE and silently got READ COMMITTED, which is the worst available
214
+ * outcome for this option: the caller believes it has a guarantee it does not
215
+ * have, and the workload that depended on it produces wrong data with no error.
216
+ *
217
+ * The TypeScript union on `TransactionOptions.isolationLevel` does not prevent
218
+ * this. It is not enforced for a JavaScript consumer of a published package, for
219
+ * a value read from config or an environment variable, or across any `as`.
220
+ * Throwing costs a caller who was already broken one clear error.
221
+ */
222
+ /**
223
+ * Run a transaction-control statement (BEGIN / COMMIT / ROLLBACK) with the same
224
+ * error translation every other query boundary gets.
225
+ *
226
+ * These four statements were the only ones issued raw, and COMMIT is the one
227
+ * that matters: Postgres reports DEFERRABLE constraint violations and a good
228
+ * share of SERIALIZABLE conflicts at COMMIT rather than at the statement that
229
+ * caused them. Unwrapped, those surfaced as a raw `DatabaseError` carrying a
230
+ * SQLSTATE in `.code`, which is the SAME property Turbine puts `TURBINE_E0NN`
231
+ * in, so `err.code === 'TURBINE_E008'` silently missed them and a `switch` on
232
+ * `.code` received a value from a foreign namespace. Worse for the retry case:
233
+ * `SerializationFailureError.isRetryable` exists so callers can build retry
234
+ * loops, and a loop keyed on it would never fire for the commit-time conflicts
235
+ * that are the main reason to run SERIALIZABLE at all.
236
+ */
237
+ async function runTxControl(client, sql) {
238
+ try {
239
+ await client.query(sql);
240
+ }
241
+ catch (err) {
242
+ throw (0, errors_js_1.wrapPgError)(err);
243
+ }
244
+ }
245
+ function resolveIsolationLevel(level) {
246
+ if (level === undefined)
247
+ return undefined;
248
+ const sql = (0, utils_js_1.ownLookup)(ISOLATION_LEVELS, level);
249
+ if (sql === undefined) {
250
+ throw new errors_js_1.ValidationError(`[turbine] $transaction: unknown isolationLevel ${JSON.stringify(level)}. ` +
251
+ `Expected one of: ${Object.keys(ISOLATION_LEVELS).join(', ')} (case-sensitive).`);
252
+ }
253
+ return sql;
254
+ }
203
255
  /**
204
256
  * Strict GUC (session variable) name: an optionally namespaced identifier such
205
257
  * as `app.current_tenant` or `search_path`. Even though the name is passed as a
@@ -1270,7 +1322,7 @@ class TurbineClient {
1270
1322
  */
1271
1323
  let began = false;
1272
1324
  try {
1273
- await client.query(this.dialect.beginStatement());
1325
+ await runTxControl(client, this.dialect.beginStatement());
1274
1326
  began = true;
1275
1327
  // Engine seam: single-writer engines scope their transaction re-entrancy
1276
1328
  // marker to the callback's async subtree (see
@@ -1278,7 +1330,7 @@ class TurbineClient {
1278
1330
  const wrap = client.wrapTransactionCallback;
1279
1331
  // `.call` erases the generic, so the callback's Promise<T> is re-asserted.
1280
1332
  const result = wrap ? (await wrap.call(client, () => fn(client))) : await fn(client);
1281
- await client.query(this.dialect.commitStatement());
1333
+ await runTxControl(client, this.dialect.commitStatement());
1282
1334
  return result;
1283
1335
  }
1284
1336
  catch (err) {
@@ -1303,6 +1355,9 @@ class TurbineClient {
1303
1355
  return this.transactionBatch(fnOrQueries);
1304
1356
  }
1305
1357
  const fn = fnOrQueries;
1358
+ // Resolve the isolation level BEFORE taking a pool slot: a bad argument is
1359
+ // the caller's bug and should not cost a connection to discover.
1360
+ const isolationSql = resolveIsolationLevel(options?.isolationLevel);
1306
1361
  const client = await this.pool.connect();
1307
1362
  const timeout = options?.timeout;
1308
1363
  /**
@@ -1335,8 +1390,7 @@ class TurbineClient {
1335
1390
  try {
1336
1391
  // BEGIN with optional isolation level, the dialect owns the keyword and
1337
1392
  // BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
1338
- const isolationSql = options?.isolationLevel ? ISOLATION_LEVELS[options.isolationLevel] : undefined;
1339
- await client.query(this.dialect.beginStatement(isolationSql));
1393
+ await runTxControl(client, this.dialect.beginStatement(isolationSql));
1340
1394
  began = true;
1341
1395
  // Apply transaction-local session context (RLS / multi-tenant GUCs).
1342
1396
  // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
@@ -1408,7 +1462,7 @@ class TurbineClient {
1408
1462
  else {
1409
1463
  result = await runCallback();
1410
1464
  }
1411
- await client.query(this.dialect.commitStatement());
1465
+ await runTxControl(client, this.dialect.commitStatement());
1412
1466
  if (this.logging) {
1413
1467
  console.log('[turbine] Transaction committed');
1414
1468
  }
@@ -364,14 +364,37 @@ async function runPipelined(client, queries, options = {}) {
364
364
  connection.sync();
365
365
  }
366
366
  }
367
- if (connection.stream.uncork) {
368
- connection.stream.uncork();
369
- }
370
367
  }
371
368
  catch (err) {
369
+ // The send path threw PART WAY THROUGH an extended-query sequence, so the
370
+ // backend has Parse/Bind bytes for the preceding queries and will never
371
+ // receive their Sync. `valueMapper: prepareValue` runs synchronously
372
+ // inside `bind`, so any param with a throwing `toPostgres`/`toJSON`, or a
373
+ // circular reference, lands here.
374
+ //
375
+ // Returning that connection to the pool is what made this severe: the pool
376
+ // reports it idle and healthy, the NEXT borrower checks it out, and its
377
+ // first query hangs forever waiting on a ReadyForQuery for a sequence that
378
+ // was never completed. The failure surfaces in an unrelated query with
379
+ // nothing pointing back here. Destroy the connection instead: one dead
380
+ // socket is recoverable, a wedged pool slot is not.
381
+ //
382
+ // Destroying the socket is the same move the timeout path above already
383
+ // makes, and node-postgres drops a client whose stream errored rather than
384
+ // returning it to the pool.
385
+ if (connection.stream.destroy) {
386
+ connection.stream.destroy(err instanceof Error ? err : new Error(String(err)));
387
+ }
372
388
  cleanup();
373
389
  reject(err);
374
390
  }
391
+ finally {
392
+ // Always uncork, including on the throw path: leaving the stream corked
393
+ // strands the buffered bytes and the socket with them.
394
+ if (connection.stream.uncork) {
395
+ connection.stream.uncork();
396
+ }
397
+ }
375
398
  });
376
399
  }
377
400
  // ---------------------------------------------------------------------------
@@ -35,7 +35,13 @@
35
35
  * - a leading UTF-8 BOM, which some editors add and remove without being asked;
36
36
  * - CRLF and lone CR line endings, so a Windows checkout of an unchanged file
37
37
  * is not reported as drift (`core.autocrlf` rewrites on checkout);
38
- * - trailing whitespace at the very end of the file, for the same reason.
38
+ * - whitespace at the very END of the file, for the same reason.
39
+ *
40
+ * Note the last one is end-of-FILE only, not per line. Trailing whitespace on an
41
+ * individual line is hashed, and changing it is reported as drift. That is the
42
+ * intended reading: nothing in a checkout rewrites it, so it got there because
43
+ * someone edited the line, and the cost of an unnecessary regeneration is lower
44
+ * than the cost of a missed one.
39
45
  *
40
46
  * Nothing else is normalized. Comments and blank lines are hashed as-is: a
41
47
  * "harmless" edit is still an edit, and reporting one costs a regeneration
@@ -36,7 +36,13 @@
36
36
  * - a leading UTF-8 BOM, which some editors add and remove without being asked;
37
37
  * - CRLF and lone CR line endings, so a Windows checkout of an unchanged file
38
38
  * is not reported as drift (`core.autocrlf` rewrites on checkout);
39
- * - trailing whitespace at the very end of the file, for the same reason.
39
+ * - whitespace at the very END of the file, for the same reason.
40
+ *
41
+ * Note the last one is end-of-FILE only, not per line. Trailing whitespace on an
42
+ * individual line is hashed, and changing it is reported as drift. That is the
43
+ * intended reading: nothing in a checkout rewrites it, so it got there because
44
+ * someone edited the line, and the cost of an unnecessary regeneration is lower
45
+ * than the cost of a missed one.
40
46
  *
41
47
  * Nothing else is normalized. Comments and blank lines are hashed as-is: a
42
48
  * "harmless" edit is still an edit, and reporting one costs a regeneration
@@ -1879,6 +1879,30 @@ class QueryInterface {
1879
1879
  // Prisma compound-unique selector expansion (before global-filter merge and
1880
1880
  // fingerprinting, so the cache only ever sees the canonical expanded where).
1881
1881
  args = maybeExpandCompoundUnique(this.tableMeta, args);
1882
+ // A findUnique whose `where` carries NO predicate must not run.
1883
+ //
1884
+ // `{ id: undefined }` is what `{ id: req.params.id }` becomes on a request
1885
+ // that omitted the parameter, or on a typo'd one. Undefined keys are dropped
1886
+ // downstream, so that used to emit `SELECT … FROM t LIMIT 1`: no predicate,
1887
+ // and the caller gets an ARBITRARY row where it asked for a specific one and
1888
+ // would have handled `null`. On an authorization check keyed by id that is a
1889
+ // silent cross-record read, and `findUniqueOrThrow` made it worse by
1890
+ // promising to throw when nothing matched and then returning a stranger's
1891
+ // row instead.
1892
+ //
1893
+ // Checked against the USER's where, before the global-filter merge, and
1894
+ // deliberately so: a tenant filter is not a unique selector, and letting it
1895
+ // satisfy this would still hand back an arbitrary row from inside the tenant.
1896
+ //
1897
+ // `findFirst` is intentionally NOT guarded. "The first row matching an
1898
+ // optional filter" is its whole contract and Prisma's `findFirst` behaves
1899
+ // the same way; ask for one row by identity with `findUnique`.
1900
+ if (whereMod.userPredicateIsEmpty(this.ctx, (args.where ?? {}))) {
1901
+ throw new errors_js_1.ValidationError(`[turbine] findUnique on "${this.table}" refused: the \`where\` clause has no predicate, ` +
1902
+ 'so this would return an arbitrary row rather than a specific one. ' +
1903
+ 'A key whose value is `undefined` does not count, check that the value you are looking up is defined. ' +
1904
+ 'If you meant "any row matching an optional filter", use `findFirst`.');
1905
+ }
1882
1906
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
1883
1907
  // relations before fingerprinting (see buildFindMany).
1884
1908
  if (args.with && this.resolveStableOrder(args.stableRelationOrder)) {
package/dist/cli/index.js CHANGED
@@ -41,7 +41,7 @@ import { configTemplate, DEFAULT_INIT_SEED_FILE, findConfigFile, loadConfigResul
41
41
  import { DESTRUCTIVE_KIND_LABEL } from './destructive.js';
42
42
  import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
43
43
  import { runMcpServer } from './mcp.js';
44
- import { buildDiffMigrationBody, collectUpDestructive, createMigration, formatChecksumMismatchError, inspectMigrationDeploy, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, } from './migrate.js';
44
+ import { buildDiffMigrationBody, collectUpDestructive, createMigration, formatChecksumMismatchError, inspectMigrationDeploy, listMigrationFiles, MIGRATION_RECIPES, migrateDeploy, migrateDown, migrateStatus, migrateUp, parseMigrationContent, } from './migrate.js';
45
45
  import { startObserve } from './observe.js';
46
46
  import { formatPrismaReport, summaryLines } from './prisma-report.js';
47
47
  import { DEFAULT_EXCLUDED_TABLES, resolvePrismaSchema } from './prisma-resolve.js';
@@ -1727,6 +1727,41 @@ async function cmdMigrateCreate(args, config) {
1727
1727
  console.log(` ${cyan('npx turbine migrate up')}`);
1728
1728
  newline();
1729
1729
  }
1730
+ /**
1731
+ * Print the SQL a `migrate up` / `migrate down` WOULD run, and execute nothing.
1732
+ *
1733
+ * `--dry-run` was parsed and then read by `push` and `deploy` only, so on
1734
+ * `migrate up` it was silently inert: the flag was accepted, no warning was
1735
+ * printed, and the migrations were applied. A flag whose whole purpose is
1736
+ * "show me what this would do to the database" that instead does it, against
1737
+ * whatever `--url` was passed, is the most dangerous shape a CLI bug can take.
1738
+ *
1739
+ * Reads through the same `parseMigrationContent` the executor uses, so what is
1740
+ * printed is what would run, rather than a second rendering that can drift.
1741
+ */
1742
+ function printMigrationDryRun(files, section) {
1743
+ const verb = section === 'up' ? 'apply' : 'roll back';
1744
+ if (files.length === 0) {
1745
+ info(`Dry run: nothing to ${verb}.`);
1746
+ newline();
1747
+ return;
1748
+ }
1749
+ info(`Dry run: would ${verb} ${bold(String(files.length))} migration(s). Nothing was executed.`);
1750
+ newline();
1751
+ for (const file of files) {
1752
+ const parsed = parseMigrationContent(readFileSync(file.path, 'utf-8'));
1753
+ const sql = section === 'up' ? parsed.up : parsed.down;
1754
+ console.log(` ${cyan(file.filename)}${parsed.noTransaction ? dim(' (no transaction)') : ''}`);
1755
+ if (sql.trim().length === 0) {
1756
+ console.log(` ${dim(`(empty ${section.toUpperCase()} section)`)}`);
1757
+ }
1758
+ else {
1759
+ for (const line of sql.split('\n'))
1760
+ console.log(` ${dim(line)}`);
1761
+ }
1762
+ newline();
1763
+ }
1764
+ }
1730
1765
  async function cmdMigrateUp(args, config) {
1731
1766
  banner();
1732
1767
  const url = requireUrl(config);
@@ -1740,6 +1775,14 @@ async function cmdMigrateUp(args, config) {
1740
1775
  newline();
1741
1776
  return;
1742
1777
  }
1778
+ if (args.dryRun) {
1779
+ const status = await migrateStatus(url, config.migrationsDir);
1780
+ let pending = status.filter((st) => !st.applied).map((st) => st.file);
1781
+ if (args.step != null && args.step > 0)
1782
+ pending = pending.slice(0, args.step);
1783
+ printMigrationDryRun(pending, 'up');
1784
+ return;
1785
+ }
1743
1786
  // Big, loud warning when bypassing drift detection, this is a deliberately
1744
1787
  // dangerous operation and the user should see it on every invocation.
1745
1788
  if (args.allowDrift) {
@@ -1961,6 +2004,14 @@ async function cmdMigrateDown(args, config) {
1961
2004
  label('Database', redactUrl(url));
1962
2005
  label('Migrations', config.migrationsDir);
1963
2006
  newline();
2007
+ if (args.dryRun) {
2008
+ const status = await migrateStatus(url, config.migrationsDir);
2009
+ // Rollback order is newest-applied first, and a migration whose file is
2010
+ // missing has no DOWN section to show.
2011
+ const applied = status.filter((st) => st.applied && !st.missingFile).map((st) => st.file);
2012
+ printMigrationDryRun(applied.reverse().slice(0, args.step ?? 1), 'down');
2013
+ return;
2014
+ }
1964
2015
  const spinner = new Spinner('Rolling back migration(s)').start();
1965
2016
  let result;
1966
2017
  try {
package/dist/client.js CHANGED
@@ -27,7 +27,7 @@ import { setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationE
27
27
  import { ObserveEngine } from './observe.js';
28
28
  import { executePipeline, pipelineSupported } from './pipeline.js';
29
29
  import { QueryInterface, } from './query/index.js';
30
- import { markTurbineParser, quoteIdent, registerUtcTemporalParsers, suggestKey, warnParserOverwrite, } from './query/utils.js';
30
+ import { markTurbineParser, ownLookup, quoteIdent, registerUtcTemporalParsers, suggestKey, warnParserOverwrite, } from './query/utils.js';
31
31
  import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
32
32
  import { createSubscription, validateChannel, } from './realtime.js';
33
33
  import { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
@@ -186,13 +186,65 @@ function warnUnknownConfigKeys(config) {
186
186
  // must never be the reason a client fails to construct.
187
187
  }
188
188
  }
189
- /** Maps isolation level names to SQL */
190
- const ISOLATION_LEVELS = {
189
+ /**
190
+ * Maps isolation level names to SQL. Null-prototype and read through
191
+ * {@link resolveIsolationLevel}, never indexed directly: an inherited key
192
+ * (`constructor`, `toString`) would otherwise resolve to a function and be
193
+ * interpolated verbatim into `BEGIN ISOLATION LEVEL …`.
194
+ */
195
+ const ISOLATION_LEVELS = Object.assign(Object.create(null), {
191
196
  ReadUncommitted: 'READ UNCOMMITTED',
192
197
  ReadCommitted: 'READ COMMITTED',
193
198
  RepeatableRead: 'REPEATABLE READ',
194
199
  Serializable: 'SERIALIZABLE',
195
- };
200
+ });
201
+ /**
202
+ * Resolve a requested isolation level, REFUSING anything not in the map.
203
+ *
204
+ * A miss used to yield `undefined`, which `beginStatement` renders as a plain
205
+ * `BEGIN`. So `isolationLevel: 'serializable'` (wrong case) asked for
206
+ * SERIALIZABLE and silently got READ COMMITTED, which is the worst available
207
+ * outcome for this option: the caller believes it has a guarantee it does not
208
+ * have, and the workload that depended on it produces wrong data with no error.
209
+ *
210
+ * The TypeScript union on `TransactionOptions.isolationLevel` does not prevent
211
+ * this. It is not enforced for a JavaScript consumer of a published package, for
212
+ * a value read from config or an environment variable, or across any `as`.
213
+ * Throwing costs a caller who was already broken one clear error.
214
+ */
215
+ /**
216
+ * Run a transaction-control statement (BEGIN / COMMIT / ROLLBACK) with the same
217
+ * error translation every other query boundary gets.
218
+ *
219
+ * These four statements were the only ones issued raw, and COMMIT is the one
220
+ * that matters: Postgres reports DEFERRABLE constraint violations and a good
221
+ * share of SERIALIZABLE conflicts at COMMIT rather than at the statement that
222
+ * caused them. Unwrapped, those surfaced as a raw `DatabaseError` carrying a
223
+ * SQLSTATE in `.code`, which is the SAME property Turbine puts `TURBINE_E0NN`
224
+ * in, so `err.code === 'TURBINE_E008'` silently missed them and a `switch` on
225
+ * `.code` received a value from a foreign namespace. Worse for the retry case:
226
+ * `SerializationFailureError.isRetryable` exists so callers can build retry
227
+ * loops, and a loop keyed on it would never fire for the commit-time conflicts
228
+ * that are the main reason to run SERIALIZABLE at all.
229
+ */
230
+ async function runTxControl(client, sql) {
231
+ try {
232
+ await client.query(sql);
233
+ }
234
+ catch (err) {
235
+ throw wrapPgError(err);
236
+ }
237
+ }
238
+ function resolveIsolationLevel(level) {
239
+ if (level === undefined)
240
+ return undefined;
241
+ const sql = ownLookup(ISOLATION_LEVELS, level);
242
+ if (sql === undefined) {
243
+ throw new ValidationError(`[turbine] $transaction: unknown isolationLevel ${JSON.stringify(level)}. ` +
244
+ `Expected one of: ${Object.keys(ISOLATION_LEVELS).join(', ')} (case-sensitive).`);
245
+ }
246
+ return sql;
247
+ }
196
248
  /**
197
249
  * Strict GUC (session variable) name: an optionally namespaced identifier such
198
250
  * as `app.current_tenant` or `search_path`. Even though the name is passed as a
@@ -1262,7 +1314,7 @@ export class TurbineClient {
1262
1314
  */
1263
1315
  let began = false;
1264
1316
  try {
1265
- await client.query(this.dialect.beginStatement());
1317
+ await runTxControl(client, this.dialect.beginStatement());
1266
1318
  began = true;
1267
1319
  // Engine seam: single-writer engines scope their transaction re-entrancy
1268
1320
  // marker to the callback's async subtree (see
@@ -1270,7 +1322,7 @@ export class TurbineClient {
1270
1322
  const wrap = client.wrapTransactionCallback;
1271
1323
  // `.call` erases the generic, so the callback's Promise<T> is re-asserted.
1272
1324
  const result = wrap ? (await wrap.call(client, () => fn(client))) : await fn(client);
1273
- await client.query(this.dialect.commitStatement());
1325
+ await runTxControl(client, this.dialect.commitStatement());
1274
1326
  return result;
1275
1327
  }
1276
1328
  catch (err) {
@@ -1295,6 +1347,9 @@ export class TurbineClient {
1295
1347
  return this.transactionBatch(fnOrQueries);
1296
1348
  }
1297
1349
  const fn = fnOrQueries;
1350
+ // Resolve the isolation level BEFORE taking a pool slot: a bad argument is
1351
+ // the caller's bug and should not cost a connection to discover.
1352
+ const isolationSql = resolveIsolationLevel(options?.isolationLevel);
1298
1353
  const client = await this.pool.connect();
1299
1354
  const timeout = options?.timeout;
1300
1355
  /**
@@ -1327,8 +1382,7 @@ export class TurbineClient {
1327
1382
  try {
1328
1383
  // BEGIN with optional isolation level, the dialect owns the keyword and
1329
1384
  // BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
1330
- const isolationSql = options?.isolationLevel ? ISOLATION_LEVELS[options.isolationLevel] : undefined;
1331
- await client.query(this.dialect.beginStatement(isolationSql));
1385
+ await runTxControl(client, this.dialect.beginStatement(isolationSql));
1332
1386
  began = true;
1333
1387
  // Apply transaction-local session context (RLS / multi-tenant GUCs).
1334
1388
  // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
@@ -1400,7 +1454,7 @@ export class TurbineClient {
1400
1454
  else {
1401
1455
  result = await runCallback();
1402
1456
  }
1403
- await client.query(this.dialect.commitStatement());
1457
+ await runTxControl(client, this.dialect.commitStatement());
1404
1458
  if (this.logging) {
1405
1459
  console.log('[turbine] Transaction committed');
1406
1460
  }
@@ -357,14 +357,37 @@ export async function runPipelined(client, queries, options = {}) {
357
357
  connection.sync();
358
358
  }
359
359
  }
360
- if (connection.stream.uncork) {
361
- connection.stream.uncork();
362
- }
363
360
  }
364
361
  catch (err) {
362
+ // The send path threw PART WAY THROUGH an extended-query sequence, so the
363
+ // backend has Parse/Bind bytes for the preceding queries and will never
364
+ // receive their Sync. `valueMapper: prepareValue` runs synchronously
365
+ // inside `bind`, so any param with a throwing `toPostgres`/`toJSON`, or a
366
+ // circular reference, lands here.
367
+ //
368
+ // Returning that connection to the pool is what made this severe: the pool
369
+ // reports it idle and healthy, the NEXT borrower checks it out, and its
370
+ // first query hangs forever waiting on a ReadyForQuery for a sequence that
371
+ // was never completed. The failure surfaces in an unrelated query with
372
+ // nothing pointing back here. Destroy the connection instead: one dead
373
+ // socket is recoverable, a wedged pool slot is not.
374
+ //
375
+ // Destroying the socket is the same move the timeout path above already
376
+ // makes, and node-postgres drops a client whose stream errored rather than
377
+ // returning it to the pool.
378
+ if (connection.stream.destroy) {
379
+ connection.stream.destroy(err instanceof Error ? err : new Error(String(err)));
380
+ }
365
381
  cleanup();
366
382
  reject(err);
367
383
  }
384
+ finally {
385
+ // Always uncork, including on the throw path: leaving the stream corked
386
+ // strands the buffered bytes and the socket with them.
387
+ if (connection.stream.uncork) {
388
+ connection.stream.uncork();
389
+ }
390
+ }
368
391
  });
369
392
  }
370
393
  // ---------------------------------------------------------------------------
@@ -35,7 +35,13 @@
35
35
  * - a leading UTF-8 BOM, which some editors add and remove without being asked;
36
36
  * - CRLF and lone CR line endings, so a Windows checkout of an unchanged file
37
37
  * is not reported as drift (`core.autocrlf` rewrites on checkout);
38
- * - trailing whitespace at the very end of the file, for the same reason.
38
+ * - whitespace at the very END of the file, for the same reason.
39
+ *
40
+ * Note the last one is end-of-FILE only, not per line. Trailing whitespace on an
41
+ * individual line is hashed, and changing it is reported as drift. That is the
42
+ * intended reading: nothing in a checkout rewrites it, so it got there because
43
+ * someone edited the line, and the cost of an unnecessary regeneration is lower
44
+ * than the cost of a missed one.
39
45
  *
40
46
  * Nothing else is normalized. Comments and blank lines are hashed as-is: a
41
47
  * "harmless" edit is still an edit, and reporting one costs a regeneration
@@ -35,7 +35,13 @@
35
35
  * - a leading UTF-8 BOM, which some editors add and remove without being asked;
36
36
  * - CRLF and lone CR line endings, so a Windows checkout of an unchanged file
37
37
  * is not reported as drift (`core.autocrlf` rewrites on checkout);
38
- * - trailing whitespace at the very end of the file, for the same reason.
38
+ * - whitespace at the very END of the file, for the same reason.
39
+ *
40
+ * Note the last one is end-of-FILE only, not per line. Trailing whitespace on an
41
+ * individual line is hashed, and changing it is reported as drift. That is the
42
+ * intended reading: nothing in a checkout rewrites it, so it got there because
43
+ * someone edited the line, and the cost of an unnecessary regeneration is lower
44
+ * than the cost of a missed one.
39
45
  *
40
46
  * Nothing else is normalized. Comments and blank lines are hashed as-is: a
41
47
  * "harmless" edit is still an edit, and reporting one costs a regeneration
@@ -1843,6 +1843,30 @@ export class QueryInterface {
1843
1843
  // Prisma compound-unique selector expansion (before global-filter merge and
1844
1844
  // fingerprinting, so the cache only ever sees the canonical expanded where).
1845
1845
  args = maybeExpandCompoundUnique(this.tableMeta, args);
1846
+ // A findUnique whose `where` carries NO predicate must not run.
1847
+ //
1848
+ // `{ id: undefined }` is what `{ id: req.params.id }` becomes on a request
1849
+ // that omitted the parameter, or on a typo'd one. Undefined keys are dropped
1850
+ // downstream, so that used to emit `SELECT … FROM t LIMIT 1`: no predicate,
1851
+ // and the caller gets an ARBITRARY row where it asked for a specific one and
1852
+ // would have handled `null`. On an authorization check keyed by id that is a
1853
+ // silent cross-record read, and `findUniqueOrThrow` made it worse by
1854
+ // promising to throw when nothing matched and then returning a stranger's
1855
+ // row instead.
1856
+ //
1857
+ // Checked against the USER's where, before the global-filter merge, and
1858
+ // deliberately so: a tenant filter is not a unique selector, and letting it
1859
+ // satisfy this would still hand back an arbitrary row from inside the tenant.
1860
+ //
1861
+ // `findFirst` is intentionally NOT guarded. "The first row matching an
1862
+ // optional filter" is its whole contract and Prisma's `findFirst` behaves
1863
+ // the same way; ask for one row by identity with `findUnique`.
1864
+ if (whereMod.userPredicateIsEmpty(this.ctx, (args.where ?? {}))) {
1865
+ throw new ValidationError(`[turbine] findUnique on "${this.table}" refused: the \`where\` clause has no predicate, ` +
1866
+ 'so this would return an arbitrary row rather than a specific one. ' +
1867
+ 'A key whose value is `undefined` does not count, check that the value you are looking up is defined. ' +
1868
+ 'If you meant "any row matching an optional filter", use `findFirst`.');
1869
+ }
1846
1870
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
1847
1871
  // relations before fingerprinting (see buildFindMany).
1848
1872
  if (args.with && this.resolveStableOrder(args.stableRelationOrder)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.60.0",
3
+ "version": "0.61.0",
4
4
  "description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",