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 +6 -6
- package/dist/cjs/cli/index.js +51 -0
- package/dist/cjs/client.js +62 -8
- package/dist/cjs/pipeline-submittable.js +26 -3
- package/dist/cjs/prisma-schema-fingerprint.d.ts +7 -1
- package/dist/cjs/prisma-schema-fingerprint.js +7 -1
- package/dist/cjs/query/builder.js +24 -0
- package/dist/cli/index.js +52 -1
- package/dist/client.js +63 -9
- package/dist/pipeline-submittable.js +26 -3
- package/dist/prisma-schema-fingerprint.d.ts +7 -1
- package/dist/prisma-schema-fingerprint.js +7 -1
- package/dist/query/builder.js +24 -0
- package/package.json +1 -1
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
|
|
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
|
|
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)** |
|
|
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,
|
|
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
|
|
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:
|
|
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
|
|
package/dist/cjs/cli/index.js
CHANGED
|
@@ -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 {
|
package/dist/cjs/client.js
CHANGED
|
@@ -193,13 +193,65 @@ function warnUnknownConfigKeys(config) {
|
|
|
193
193
|
// must never be the reason a client fails to construct.
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
|
-
/**
|
|
197
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
* -
|
|
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
|
-
/**
|
|
190
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
* -
|
|
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
|
package/dist/query/builder.js
CHANGED
|
@@ -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.
|
|
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).",
|