turbine-orm 0.72.0 → 0.73.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
@@ -20,7 +20,7 @@ Six reasons, each with the mechanism that makes it true:
20
20
  2. **Written from scratch.** Turbine is not a layer over Knex or a query-builder library. Query compilation is plain string building with an FNV-1a shape fingerprint into a bounded LRU of SQL templates, so there is no plan cache to size and no compiler running on your event loop.
21
21
  3. **Nested relations in one statement.** A `with` clause compiles to correlated `json_agg` subqueries, so users with posts with comments is one round trip, typed end to end: `users[0].posts[0].comments[0].author.name` autocompletes with no annotation.
22
22
  4. **Close to raw SQL.** In the last published run, Turbine's overhead over a hand-written `pg` control was 1.08x by geometric mean. The table is below; the losses are stated with the wins.
23
- 5. **Agents get typed tools, not a SQL prompt.** `npx turbine mcp` exposes eleven read-only MCP tools, including a relation graph and a join-path finder that returns the `with` clause to write. Every tool runs inside `BEGIN READ ONLY`, and PII-tagged columns are redacted before rows reach a model.
23
+ 5. **Agents get a skill and typed tools, not a SQL prompt.** `npx turbine skill` installs a query-writing skill whose every claim is executed against a live database before release; `npx turbine mcp` exposes eleven read-only MCP tools, including a relation graph and a join-path finder that returns the `with` clause to write. Every tool runs inside `BEGIN READ ONLY`, and PII-tagged columns are redacted before rows reach a model.
24
24
  6. **The dangerous operations ask first.** Destructive migration statements refuse to run without typed consent. `update`/`delete` with an empty `where` throws. Columns tagged `pii: true` are excluded from the emitted SQL's projections. `turbine doctor` reports missing FK indexes offline, no account, no telemetry.
25
25
 
26
26
  ## Benchmarks
@@ -216,12 +216,15 @@ Every error extends `TurbineError` with a stable code (`TURBINE_E001` through `E
216
216
 
217
217
  ## Built for agents
218
218
 
219
- An agent pointed at a database usually gets a connection string and guesses. Turbine gives it typed tools instead:
219
+ An agent pointed at a database usually gets a connection string and guesses. Turbine gives it a skill and typed tools instead:
220
220
 
221
221
  ```bash
222
+ npx turbine skill # install the query-writing skill into the project
222
223
  npx turbine mcp # read-only MCP server over JSON-RPC stdio, ships in the package
223
224
  ```
224
225
 
226
+ The skill covers what an agent gets wrong on a schema it has not seen: `with` versus Prisma's `include`, how relation names are derived, what `select` may name, relation filters, the `having` shape, JSON paths, and which error each mistake produces. **Every factual claim in it is executed against a live database before release**, so it cannot drift from the ORM the way documentation usually does. `--agents` prints a short block for `AGENTS.md` instead.
227
+
225
228
  Eleven tools, every one inside `BEGIN READ ONLY` with a statement timeout, so an agent cannot mutate anything through this server:
226
229
 
227
230
  | Tool | What it answers |
@@ -238,7 +241,7 @@ Eleven tools, every one inside `BEGIN READ ONLY` with a statement timeout, so an
238
241
  | `migrate_status` | Applied vs pending migrations, without applying anything. |
239
242
  | `doctor_report` | Missing relation indexes, from the same advisor `turbine doctor` uses. |
240
243
 
241
- The rest of the agent story is structural: query args are fully typed, so a wrong query is a compile error the agent can read; errors carry stable codes it can branch on; and [llms.txt](https://turbineorm.dev/llms.txt) / [llms-full.txt](https://turbineorm.dev/llms-full.txt) give it the docs in fetchable form. Setup for Claude Code and Cursor, plus a drop-in instructions snippet: [turbineorm.dev/ai-agents](https://turbineorm.dev/ai-agents).
244
+ The rest of the agent story is structural: query args are fully typed, so a wrong query is a compile error the agent can read; errors carry stable codes it can branch on; an unrecognized query option warns instead of being silently dropped; and [llms.txt](https://turbineorm.dev/llms.txt) / [llms-full.txt](https://turbineorm.dev/llms-full.txt) give it the docs in fetchable form. Setup for Claude Code and Cursor, plus a drop-in instructions snippet: [turbineorm.dev/ai-agents](https://turbineorm.dev/ai-agents).
242
245
 
243
246
  ## Safety tooling
244
247
 
@@ -18,6 +18,7 @@
18
18
  * turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
19
19
  * turbine mcp , Start read-only MCP server over JSON-RPC stdio
20
20
  * turbine observe , Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
21
+ * turbine skill - Install the agent query skill (--print, --agents, --dir <path>)
21
22
  *
22
23
  * Usage:
23
24
  * DATABASE_URL=postgres://... npx turbine generate
@@ -113,6 +114,12 @@ export interface CliArgs {
113
114
  * not be turned into a failed install.
114
115
  */
115
116
  ifDb?: boolean;
117
+ /** `skill --print`: write the skill to stdout instead of installing it. */
118
+ print?: boolean;
119
+ /** `skill --agents`: print the AGENTS.md instructions block instead. */
120
+ agents?: boolean;
121
+ /** `skill --dir <path>`: the skills root to install into (default `.claude/skills`). */
122
+ dir?: string;
116
123
  }
117
124
  export declare function parseArgs(argv?: string[]): CliArgs;
118
125
  /**
@@ -19,6 +19,7 @@
19
19
  * turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
20
20
  * turbine mcp , Start read-only MCP server over JSON-RPC stdio
21
21
  * turbine observe , Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
22
+ * turbine skill - Install the agent query skill (--print, --agents, --dir <path>)
22
23
  *
23
24
  * Usage:
24
25
  * DATABASE_URL=postgres://... npx turbine generate
@@ -227,6 +228,17 @@ function parseArgs(argv = process.argv.slice(2)) {
227
228
  case '--allow-pooler':
228
229
  result.allowPooler = true;
229
230
  break;
231
+ // `turbine skill`
232
+ case '--print':
233
+ result.print = true;
234
+ break;
235
+ case '--agents':
236
+ result.agents = true;
237
+ break;
238
+ case '--dir':
239
+ result.dir = next;
240
+ i++;
241
+ break;
230
242
  case '--zod':
231
243
  result.zod = true;
232
244
  break;
@@ -4001,6 +4013,7 @@ function showHelp() {
4001
4013
  console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI ${(0, ui_js_1.dim)('(--write for writes, --demo for a sample DB)')}`);
4002
4014
  console.log(` ${(0, ui_js_1.cyan)('mcp')} Start read-only MCP server over stdio`);
4003
4015
  console.log(` ${(0, ui_js_1.cyan)('observe')} Launch metrics dashboard ${(0, ui_js_1.dim)('(requires TURBINE_OBSERVE_URL)')}`);
4016
+ console.log(` ${(0, ui_js_1.cyan)('skill')} Install the agent query skill ${(0, ui_js_1.dim)('(--print, --agents, --dir <path>)')}`);
4004
4017
  (0, ui_js_1.newline)();
4005
4018
  console.log(` ${(0, ui_js_1.bold)('Options:')}`);
4006
4019
  console.log(` ${(0, ui_js_1.cyan)('--url, -u')} ${(0, ui_js_1.dim)('<url>')} Postgres connection string`);
@@ -4051,6 +4064,103 @@ function showHelp() {
4051
4064
  (0, ui_js_1.newline)();
4052
4065
  }
4053
4066
  // ---------------------------------------------------------------------------
4067
+ // Agent skill
4068
+ // ---------------------------------------------------------------------------
4069
+ /**
4070
+ * turbine-orm's own package root, found by walking up from the running script.
4071
+ *
4072
+ * `process.argv[1]` rather than `import.meta.url` so the same source compiles
4073
+ * for both the ESM and CJS builds, and realpath first because `npx turbine`
4074
+ * runs through a `node_modules/.bin` symlink whose dirname is the CONSUMER's
4075
+ * tree, where this package.json does not exist.
4076
+ */
4077
+ function ownPackageRoot() {
4078
+ try {
4079
+ let entry = process.argv[1] ?? '';
4080
+ try {
4081
+ entry = (0, node_fs_1.realpathSync)(entry);
4082
+ }
4083
+ catch {
4084
+ // keep the raw path if realpath fails (e.g. deleted cwd)
4085
+ }
4086
+ let dir = (0, node_path_1.dirname)(entry);
4087
+ for (let i = 0; i < 6; i++) {
4088
+ const candidate = (0, node_path_1.resolve)(dir, 'package.json');
4089
+ if ((0, node_fs_1.existsSync)(candidate)) {
4090
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)(candidate, 'utf8'));
4091
+ if (pkg.name === 'turbine-orm')
4092
+ return dir;
4093
+ }
4094
+ const parent = (0, node_path_1.dirname)(dir);
4095
+ if (parent === dir)
4096
+ break;
4097
+ dir = parent;
4098
+ }
4099
+ }
4100
+ catch {
4101
+ // fall through
4102
+ }
4103
+ return undefined;
4104
+ }
4105
+ /** The instructions block for a project's AGENTS.md / CLAUDE.md. */
4106
+ const AGENTS_SNIPPET = `## Database access (Turbine ORM)
4107
+
4108
+ - Queries go through the generated client. Read \`generated/turbine/types.ts\` for
4109
+ the entity and input types before writing one; the names there are the truth.
4110
+ - Relations are \`with\`, never Prisma's \`include\`. An unrecognized option is
4111
+ ignored, so an \`include\` returns rows with the relation missing.
4112
+ - \`select\` and \`omit\` name columns only. A relation named in \`select\` throws.
4113
+ - \`findUnique\` needs a unique key. Use \`findFirst\` for "any row matching a
4114
+ filter", with an \`orderBy\` if which row matters.
4115
+ - Re-run \`npx turbine generate\` after any schema change, then \`tsc --noEmit\`:
4116
+ an invalid query is a type error, so the type checker is the fastest reviewer.
4117
+ - Never write \`includePii: true\`, \`skipGlobalFilters: true\` or
4118
+ \`allowFullTableScan: true\`. Those options take an imported \`UNSAFE\` symbol and
4119
+ nothing else; any other value throws.
4120
+ - Full query reference: https://turbineorm.dev/llms.txt
4121
+ `;
4122
+ /**
4123
+ * Install the packaged query-writing skill into the project.
4124
+ *
4125
+ * The skill is a file in the published tarball rather than something generated
4126
+ * here, so what an agent reads is exactly what the repository tests: every
4127
+ * factual claim in it is executed against a live database by
4128
+ * `evals/src/verify-skill.ts` on each release.
4129
+ */
4130
+ function cmdSkill(args) {
4131
+ if (args.agents === true) {
4132
+ console.log(AGENTS_SNIPPET);
4133
+ return;
4134
+ }
4135
+ const root = ownPackageRoot();
4136
+ const source = root ? (0, node_path_1.resolve)(root, 'skills', 'turbine-orm', 'SKILL.md') : undefined;
4137
+ if (!source || !(0, node_fs_1.existsSync)(source)) {
4138
+ (0, ui_js_1.error)('Could not find the packaged skill inside turbine-orm.');
4139
+ (0, ui_js_1.newline)();
4140
+ console.log(` ${(0, ui_js_1.dim)('Read it online instead:')} ${(0, ui_js_1.cyan)('https://turbineorm.dev/ai-agents')}`);
4141
+ (0, ui_js_1.newline)();
4142
+ process.exit(1);
4143
+ }
4144
+ const body = (0, node_fs_1.readFileSync)(source, 'utf8');
4145
+ if (args.print === true) {
4146
+ process.stdout.write(body);
4147
+ return;
4148
+ }
4149
+ const skillsDir = args.dir ?? (0, node_path_1.join)('.claude', 'skills');
4150
+ const target = (0, node_path_1.resolve)(process.cwd(), skillsDir, 'turbine-orm', 'SKILL.md');
4151
+ const existed = (0, node_fs_1.existsSync)(target);
4152
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(target), { recursive: true });
4153
+ (0, node_fs_1.writeFileSync)(target, body);
4154
+ (0, ui_js_1.newline)();
4155
+ (0, ui_js_1.success)(`${existed ? 'Updated' : 'Installed'} the Turbine query skill`);
4156
+ console.log(` ${(0, ui_js_1.dim)((0, node_path_1.relative)(process.cwd(), target))}`);
4157
+ (0, ui_js_1.newline)();
4158
+ console.log(` ${(0, ui_js_1.dim)('Also worth doing:')}`);
4159
+ console.log(` ${(0, ui_js_1.dim)('-')} connect the read-only MCP server: ${(0, ui_js_1.cyan)('npx turbine mcp')}`);
4160
+ console.log(` ${(0, ui_js_1.dim)('-')} add the instructions block to AGENTS.md: ${(0, ui_js_1.cyan)('npx turbine skill --agents')}`);
4161
+ (0, ui_js_1.newline)();
4162
+ }
4163
+ // ---------------------------------------------------------------------------
4054
4164
  // Version
4055
4165
  // ---------------------------------------------------------------------------
4056
4166
  function showVersion() {
@@ -4199,6 +4309,9 @@ async function main() {
4199
4309
  case 'observe':
4200
4310
  await cmdObserve(args);
4201
4311
  break;
4312
+ case 'skill':
4313
+ cmdSkill(args);
4314
+ break;
4202
4315
  default:
4203
4316
  (0, ui_js_1.error)(`Unknown command: ${(0, ui_js_1.bold)(args.command)}`);
4204
4317
  (0, ui_js_1.newline)();
@@ -148,10 +148,11 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
148
148
  */
149
149
  private get capabilities();
150
150
  /**
151
- * The `limit` a query actually emits: the explicit `limit`, Prisma's `take`
152
- * alias, then the client-level `defaultLimit`. Shared by {@link buildFind} and
153
- * the {@link findMany} zero short-circuit so the two can never disagree about
154
- * which limit is in force.
151
+ * The `limit` a query actually emits: the explicit `limit`, then the
152
+ * client-level `defaultLimit`. Prisma's `take` alias is already folded into
153
+ * `limit` by `normalizeArgs`, so there is one spelling by the time this runs.
154
+ * Shared by {@link buildFind} and the {@link findMany} zero short-circuit so
155
+ * the two can never disagree about which limit is in force.
155
156
  */
156
157
  private effectiveLimit;
157
158
  /**
@@ -186,7 +187,19 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
186
187
  * which is the divergence class the projection resolver already cost.
187
188
  */
188
189
  private withDeclaredRelationNames;
190
+ /**
191
+ * Caller args in canonical form: declared relation spellings, and `take` /
192
+ * `skip` folded into `limit` / `offset`.
193
+ *
194
+ * Same composition as `QueryInterface.normalizeArgs` and here for the same
195
+ * reason the method above is here: nothing about a parallel implementation
196
+ * makes a core rule arrive on its own, and an engine that reads `take` but
197
+ * not `skip` pages differently from one that reads both.
198
+ */
199
+ private normalizeArgs;
189
200
  private assertNoForceCustomPlan;
201
+ /** See query/compound-unique.ts: one rule and one message across engines. */
202
+ private assertIdentifiesOneRow;
190
203
  private assertPagination;
191
204
  /** A predicate that is always false, the empty-`in` / contradiction sentinel. */
192
205
  private alwaysFalse;
package/dist/cjs/powql.js CHANGED
@@ -77,6 +77,7 @@ const powdb_js_1 = require("./powdb.js");
77
77
  const aggregates_js_1 = require("./query/aggregates.js");
78
78
  const compound_unique_js_1 = require("./query/compound-unique.js");
79
79
  const filters_js_1 = require("./query/filters.js");
80
+ const option_surface_js_1 = require("./query/option-surface.js");
80
81
  const relation_names_js_1 = require("./query/relation-names.js");
81
82
  // The privilege sentinel and its resolver: `includePii` / `allowFullTableScan`
82
83
  // are unlocked ONLY by the UNSAFE symbol, on this engine exactly as on the SQL
@@ -374,13 +375,14 @@ class PowqlInterface {
374
375
  return this.pool.capabilities ?? powdb_js_1.ALL_POWDB_CAPABILITIES;
375
376
  }
376
377
  /**
377
- * The `limit` a query actually emits: the explicit `limit`, Prisma's `take`
378
- * alias, then the client-level `defaultLimit`. Shared by {@link buildFind} and
379
- * the {@link findMany} zero short-circuit so the two can never disagree about
380
- * which limit is in force.
378
+ * The `limit` a query actually emits: the explicit `limit`, then the
379
+ * client-level `defaultLimit`. Prisma's `take` alias is already folded into
380
+ * `limit` by `normalizeArgs`, so there is one spelling by the time this runs.
381
+ * Shared by {@link buildFind} and the {@link findMany} zero short-circuit so
382
+ * the two can never disagree about which limit is in force.
381
383
  */
382
384
  effectiveLimit(args) {
383
- return args.limit ?? args.take ?? this.defaultLimit;
385
+ return args.limit ?? this.defaultLimit;
384
386
  }
385
387
  /**
386
388
  * Reject a negative `limit` / `offset` before it reaches the engine. PowDB
@@ -419,12 +421,28 @@ class PowqlInterface {
419
421
  const normalized = (0, relation_names_js_1.normalizeWithClause)(this.schema, this.table, args.with);
420
422
  return normalized === args.with ? args : { ...args, with: normalized };
421
423
  }
424
+ /**
425
+ * Caller args in canonical form: declared relation spellings, and `take` /
426
+ * `skip` folded into `limit` / `offset`.
427
+ *
428
+ * Same composition as `QueryInterface.normalizeArgs` and here for the same
429
+ * reason the method above is here: nothing about a parallel implementation
430
+ * makes a core rule arrive on its own, and an engine that reads `take` but
431
+ * not `skip` pages differently from one that reads both.
432
+ */
433
+ normalizeArgs(args) {
434
+ return (0, utils_js_1.normalizePagination)(this.withDeclaredRelationNames(args));
435
+ }
422
436
  assertNoForceCustomPlan(args) {
423
437
  if (args?.forceCustomPlan !== true)
424
438
  return;
425
439
  throw new errors_js_1.UnsupportedFeatureError('The forceCustomPlan query option', 'powdb', 'Forcing a per-query custom plan means keeping the statement out of the PostgreSQL plan cache, and PowDB ' +
426
440
  'has no such cache to keep it out of. Remove the option, or set it only on PostgreSQL queries.');
427
441
  }
442
+ /** See query/compound-unique.ts: one rule and one message across engines. */
443
+ assertIdentifiesOneRow(where) {
444
+ (0, compound_unique_js_1.assertWhereIdentifiesOneRow)(this.meta, this.table, where);
445
+ }
428
446
  assertPagination(limit, offset, context) {
429
447
  for (const [name, value] of [
430
448
  ['limit', limit],
@@ -1338,6 +1356,11 @@ class PowqlInterface {
1338
1356
  }
1339
1357
  /** Run a method body through the middleware chain (mirrors QueryInterface). */
1340
1358
  async withMiddleware(action, args, executor) {
1359
+ // The unknown-key diagnostic, at the same seam and for the same reason as
1360
+ // `QueryInterface.executeWithMiddleware`. The option surface is the CORE
1361
+ // one because these args ARE core's args; PowDB reads them, it does not
1362
+ // define them.
1363
+ (0, option_surface_js_1.warnUnknownQueryOptions)(this.table, action, args);
1341
1364
  if (this.middlewares.length === 0)
1342
1365
  return executor();
1343
1366
  let index = 0;
@@ -1363,7 +1386,7 @@ class PowqlInterface {
1363
1386
  // -------------------------------------------------------------------------
1364
1387
  async findMany(args = {}) {
1365
1388
  this.assertNoForceCustomPlan(args);
1366
- args = this.withDeclaredRelationNames(args);
1389
+ args = this.normalizeArgs(args);
1367
1390
  return this.withMiddleware('findMany', args, async () => {
1368
1391
  // `limit: 0` means "no rows" (SQL `LIMIT 0`), and answering it client-side
1369
1392
  // is correct on every engine version: PowDB's projection fast path returned
@@ -1503,6 +1526,12 @@ class PowqlInterface {
1503
1526
  * too, so both engines agree.
1504
1527
  */
1505
1528
  async explain(args = {}) {
1529
+ // `explain` must compile the statement `findMany` would run, so it
1530
+ // normalizes the same args the same way. Without this, explaining a query
1531
+ // written with `take` / `skip` would report a plan for a DIFFERENT
1532
+ // statement than the one that executes, which is the one thing a
1533
+ // diagnostic must not do.
1534
+ args = this.normalizeArgs(args);
1506
1535
  const params = [];
1507
1536
  const { powql } = await this.buildFind(args, params);
1508
1537
  const { rows } = await this.exec(`explain ${powql}`, params, args.timeout, 'explain');
@@ -1515,7 +1544,7 @@ class PowqlInterface {
1515
1544
  }
1516
1545
  async findUnique(args) {
1517
1546
  this.assertNoForceCustomPlan(args);
1518
- args = this.withDeclaredRelationNames(args);
1547
+ args = this.normalizeArgs(args);
1519
1548
  // Prisma compound-unique selector → column conjunction (engine parity with
1520
1549
  // the SQL findUnique family; pure metadata, so this is a one-line adoption).
1521
1550
  if (args.where) {
@@ -1523,6 +1552,11 @@ class PowqlInterface {
1523
1552
  if (expanded !== args.where)
1524
1553
  args = { ...args, where: expanded };
1525
1554
  }
1555
+ // AFTER the selector expansion, so a compound selector counts as the key it
1556
+ // is. Same rule and same message as the SQL engines: a `where` that matches
1557
+ // many rows plus `limit 1` returns an arbitrary one of them, and PowDB has
1558
+ // no more of an ordering guarantee there than Postgres does.
1559
+ this.assertIdentifiesOneRow(args.where);
1526
1560
  return this.withMiddleware('findUnique', args, async () => {
1527
1561
  const { rows, native, nestedPlans, linkPlans, residualWith, forcedPk } = await this.runFind({ ...args, limit: 1 }, 'findUnique');
1528
1562
  if (!rows.length)
@@ -1540,7 +1574,7 @@ class PowqlInterface {
1540
1574
  });
1541
1575
  }
1542
1576
  async findFirst(args = {}) {
1543
- args = this.withDeclaredRelationNames(args);
1577
+ args = this.normalizeArgs(args);
1544
1578
  this.assertNoForceCustomPlan(args);
1545
1579
  return this.withMiddleware('findFirst', args, async () => {
1546
1580
  const { rows, native, nestedPlans, linkPlans, residualWith, forcedPk } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
@@ -1894,7 +1928,7 @@ class PowqlInterface {
1894
1928
  * one loader chunk (the loader limits per chunk, the join once globally).
1895
1929
  */
1896
1930
  joinEligible(rel, opt, args, parentCount) {
1897
- const effLimit = args.limit ?? args.take ?? this.defaultLimit;
1931
+ const effLimit = args.limit ?? this.defaultLimit;
1898
1932
  if (effLimit !== undefined || args.offset)
1899
1933
  return false;
1900
1934
  const options = (opt === true ? {} : opt);
@@ -551,6 +551,24 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
551
551
  * query/relation-names.ts.
552
552
  */
553
553
  private withDeclaredRelationNames;
554
+ /**
555
+ * Caller args in canonical form: declared relation spellings, and the Prisma
556
+ * pagination aliases folded into `limit` / `offset`.
557
+ *
558
+ * ONE method rather than two calls at each seam, because the two
559
+ * normalizations have the same requirement and the same failure mode: both
560
+ * must happen before `withFingerprint` / the cache key, and a seam that
561
+ * applies one but not the other is a seam where the alias survives into a
562
+ * fingerprint. Both return their input by reference when there was nothing to
563
+ * change, so the common path still allocates nothing.
564
+ */
565
+ private normalizeArgs;
566
+ /**
567
+ * Refuse a `findUnique` whose `where` names no unique key. The rule and the
568
+ * message live in query/compound-unique.ts, beside the definition of what
569
+ * counts as unique and shared with the PowDB engine.
570
+ */
571
+ private assertFindUniqueKey;
554
572
  /**
555
573
  * Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
556
574
  * explicit one, recursing into nested `with`. Returns a CLONED clause (user
@@ -57,6 +57,7 @@ const aggMod = __importStar(require("./aggregates.js"));
57
57
  const batched_loader_js_1 = require("./batched-loader.js");
58
58
  const compound_unique_js_1 = require("./compound-unique.js");
59
59
  const filters_js_1 = require("./filters.js");
60
+ const option_surface_js_1 = require("./option-surface.js");
60
61
  const relation_names_js_1 = require("./relation-names.js");
61
62
  const relationsMod = __importStar(require("./relations.js"));
62
63
  const types_js_1 = require("./types.js");
@@ -1121,6 +1122,28 @@ class QueryInterface {
1121
1122
  const normalized = (0, relation_names_js_1.normalizeWithClause)(this.schema, this.table, args.with);
1122
1123
  return normalized === args.with ? args : { ...args, with: normalized };
1123
1124
  }
1125
+ /**
1126
+ * Caller args in canonical form: declared relation spellings, and the Prisma
1127
+ * pagination aliases folded into `limit` / `offset`.
1128
+ *
1129
+ * ONE method rather than two calls at each seam, because the two
1130
+ * normalizations have the same requirement and the same failure mode: both
1131
+ * must happen before `withFingerprint` / the cache key, and a seam that
1132
+ * applies one but not the other is a seam where the alias survives into a
1133
+ * fingerprint. Both return their input by reference when there was nothing to
1134
+ * change, so the common path still allocates nothing.
1135
+ */
1136
+ normalizeArgs(args) {
1137
+ return (0, utils_js_1.normalizePagination)(this.withDeclaredRelationNames(args));
1138
+ }
1139
+ /**
1140
+ * Refuse a `findUnique` whose `where` names no unique key. The rule and the
1141
+ * message live in query/compound-unique.ts, beside the definition of what
1142
+ * counts as unique and shared with the PowDB engine.
1143
+ */
1144
+ assertFindUniqueKey(where) {
1145
+ (0, compound_unique_js_1.assertWhereIdentifiesOneRow)(this.tableMeta, this.table, where);
1146
+ }
1124
1147
  /**
1125
1148
  * Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
1126
1149
  * explicit one, recursing into nested `with`. Returns a CLONED clause (user
@@ -1260,7 +1283,7 @@ class QueryInterface {
1260
1283
  return false;
1261
1284
  if (!isEmptyOrderBy(args.orderBy))
1262
1285
  return false;
1263
- if (args.limit !== undefined || args.take !== undefined || args.offset !== undefined)
1286
+ if (args.limit !== undefined || args.offset !== undefined)
1264
1287
  return true;
1265
1288
  return this.cursorFields(args.cursor).length > 0;
1266
1289
  }
@@ -1325,7 +1348,6 @@ class QueryInterface {
1325
1348
  const shape = [
1326
1349
  cursorFields.length > 0 ? 'cursor' : '',
1327
1350
  args?.limit !== undefined ? 'limit' : '',
1328
- args?.take !== undefined ? 'take' : '',
1329
1351
  args?.offset !== undefined ? 'offset' : '',
1330
1352
  ]
1331
1353
  .filter(Boolean)
@@ -1455,7 +1477,7 @@ class QueryInterface {
1455
1477
  * not an estimate.
1456
1478
  */
1457
1479
  autoParentBound(args) {
1458
- return args?.take ?? args?.limit ?? this.defaultLimit;
1480
+ return args?.limit ?? this.defaultLimit;
1459
1481
  }
1460
1482
  /**
1461
1483
  * The parent-row count at which `'auto'` stops preferring the single-statement
@@ -1779,7 +1801,7 @@ class QueryInterface {
1779
1801
  // Declared relation spellings first, exactly as the join path does in
1780
1802
  // buildFindMany: the loader reads `args.with` itself, so without this the
1781
1803
  // two strategies would disagree about which relation names are valid.
1782
- args = this.withDeclaredRelationNames(args);
1804
+ args = this.normalizeArgs(args);
1783
1805
  // Stable relation order (opt-in): the batched loader forwards each relation's
1784
1806
  // orderBy into its follow-up query, so filling the synthesized PK order here
1785
1807
  // makes the batched output deterministic exactly like the join path.
@@ -2166,6 +2188,12 @@ class QueryInterface {
2166
2188
  */
2167
2189
  async executeWithMiddleware(action, args, executor) {
2168
2190
  this.currentAction = action;
2191
+ // Every public operation passes through here with its own name and the
2192
+ // caller's args, which makes this the one place the unknown-key diagnostic
2193
+ // can be complete. Putting it in each method instead would mean fifteen
2194
+ // sites and a new one every time an operation is added, which is precisely
2195
+ // how the surface it checks came to need checking.
2196
+ (0, option_surface_js_1.warnUnknownQueryOptions)(this.table, action, args);
2169
2197
  if (this.middlewares.length === 0) {
2170
2198
  return executor();
2171
2199
  }
@@ -2194,7 +2222,7 @@ class QueryInterface {
2194
2222
  // changes the warning text from the declared name back to the caller's and
2195
2223
  // nothing else). It is here so the invariant holds at the seam rather than
2196
2224
  // depending on every consumer to re-establish it.
2197
- args = this.withDeclaredRelationNames(args);
2225
+ args = this.normalizeArgs(args);
2198
2226
  return this.executeWithMiddleware('findUnique', args, async () => {
2199
2227
  if (args.with) {
2200
2228
  const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
@@ -2223,7 +2251,7 @@ class QueryInterface {
2223
2251
  */
2224
2252
  async runFindUniqueBatched(args) {
2225
2253
  // Declared relation spellings first, see runFindManyBatched.
2226
- args = this.withDeclaredRelationNames(args);
2254
+ args = this.normalizeArgs(args);
2227
2255
  // Stable relation order (opt-in), see runFindManyBatched.
2228
2256
  const withClause = this.resolveStableOrder(args.stableRelationOrder)
2229
2257
  ? this.applyStableRelationOrder(args.with, this.table)
@@ -2277,9 +2305,22 @@ class QueryInterface {
2277
2305
  'A key whose value is `undefined` does not count, check that the value you are looking up is defined. ' +
2278
2306
  'If you meant "any row matching an optional filter", use `findFirst`.');
2279
2307
  }
2308
+ // ...and a where that HAS a predicate but does not name a unique key is the
2309
+ // same hazard one step along (0.73.0). `findUnique({ where: { status:
2310
+ // 'active' } })` used to emit `WHERE status = $1 LIMIT 1` with no ORDER BY:
2311
+ // one row out of many, chosen by the engine, different between two calls
2312
+ // with the same argument and between two plans for the same call. The
2313
+ // caller who wrote `findUnique` asked for the row, not a row, and the
2314
+ // `null` branch they wrote reads as "no such row" when it means "none
2315
+ // matched this filter".
2316
+ //
2317
+ // Checked against the USER's where for the same reason as the guard above:
2318
+ // a global filter is not an identity, and letting one satisfy this would
2319
+ // hand back an arbitrary row from inside the tenant.
2320
+ this.assertFindUniqueKey(args.where);
2280
2321
  // Declared relation spellings first, before stable-order and the
2281
2322
  // fingerprint (see buildFindMany).
2282
- args = this.withDeclaredRelationNames(args);
2323
+ args = this.normalizeArgs(args);
2283
2324
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
2284
2325
  // relations before fingerprinting (see buildFindMany).
2285
2326
  if (args.with && this.resolveStableOrder(args.stableRelationOrder)) {
@@ -2428,7 +2469,7 @@ class QueryInterface {
2428
2469
  // changes the warning text from the declared name back to the caller's and
2429
2470
  // nothing else). It is here so the invariant holds at the seam rather than
2430
2471
  // depending on every consumer to re-establish it.
2431
- args = this.withDeclaredRelationNames(args);
2472
+ args = this.normalizeArgs(args);
2432
2473
  this.maybeWarnUnlimited(args);
2433
2474
  this.maybeWarnUnorderedPage(args);
2434
2475
  // Dev-only: warn on deeply nested with clauses
@@ -2536,7 +2577,7 @@ class QueryInterface {
2536
2577
  return;
2537
2578
  if (this.defaultLimit !== undefined)
2538
2579
  return;
2539
- const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined || args?.cursor !== undefined;
2580
+ const hasExplicitLimit = args?.limit !== undefined || args?.cursor !== undefined;
2540
2581
  if (hasExplicitLimit)
2541
2582
  return;
2542
2583
  if (this.whereMatchesAtMostOneRow(args?.where))
@@ -2615,7 +2656,7 @@ class QueryInterface {
2615
2656
  // pass and the fingerprint below and before any of the six `with` walkers,
2616
2657
  // so none of them needs to know a relation has two accepted spellings and
2617
2658
  // both spellings share one cache entry. See query/relation-names.ts.
2618
- args = this.withDeclaredRelationNames(args);
2659
+ args = this.normalizeArgs(args);
2619
2660
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
2620
2661
  // relations BEFORE fingerprinting, so the two orderings get distinct cache
2621
2662
  // entries and every downstream path (SQL build, collect, parser) inherits it.
@@ -2746,7 +2787,7 @@ class QueryInterface {
2746
2787
  // the caller's column order, so a permuted array rebuilds different SQL and
2747
2788
  // must not collapse onto the same cache entry (would trip the cross-check).
2748
2789
  const distinctFp = args?.distinct ? args.distinct.join(',') : '';
2749
- const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
2790
+ const effectiveLimit = args?.limit ?? this.defaultLimit;
2750
2791
  // On engines that inline the literal LIMIT/OFFSET into the SQL text
2751
2792
  // (dialect.inlineLimitOffset, MySQL), the value is part of the SQL, not the
2752
2793
  // params, so it MUST be part of the fingerprint or two different limits share
@@ -3032,8 +3073,19 @@ class QueryInterface {
3032
3073
  * non-empty.
3033
3074
  */
3034
3075
  async *streamRaw(args, action) {
3076
+ // Pagination aliases folded BEFORE the speculative build below, which
3077
+ // spreads `args` and overrides `limit`. A surviving `take` would reach that
3078
+ // spread alongside the override and be read as two different values for one
3079
+ // bound. Relation names are left to `buildFindMany`, which both branches go
3080
+ // through.
3081
+ args = (0, utils_js_1.normalizePagination)(args);
3035
3082
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
3036
3083
  this.currentAction = action;
3084
+ // The two streaming methods do NOT go through `executeWithMiddleware`, so
3085
+ // the unknown-key diagnostic has to be hung here as well or a stream would
3086
+ // be the one read that silently drops an `include`. Both public methods
3087
+ // reach this, and each passes its own name.
3088
+ (0, option_surface_js_1.warnUnknownQueryOptions)(this.table, action, args);
3037
3089
  // Streaming is ALREADY immune to the generic-plan cliff: the speculative
3038
3090
  // fetch has never passed a prepared name, and the cursor path runs through
3039
3091
  // DECLARE, so neither statement enters the plan cache. `preparedNameFor` is
@@ -3214,7 +3266,7 @@ class QueryInterface {
3214
3266
  // changes the warning text from the declared name back to the caller's and
3215
3267
  // nothing else). It is here so the invariant holds at the seam rather than
3216
3268
  // depending on every consumer to re-establish it.
3217
- args = this.withDeclaredRelationNames(args);
3269
+ args = this.normalizeArgs(args);
3218
3270
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
3219
3271
  if (args?.with) {
3220
3272
  const strategy = this.resolveLoadStrategy(args.relationLoadStrategy);
@@ -49,3 +49,47 @@ import type { TableMetadata } from '../schema.js';
49
49
  * unknown-column error at SQL-build time.
50
50
  */
51
51
  export declare function expandCompoundUniqueWhere(meta: TableMetadata, where: Record<string, unknown>): Record<string, unknown>;
52
+ /**
53
+ * Every column set that identifies AT MOST ONE ROW of this table.
54
+ *
55
+ * Same sources and the same partial-index rule as {@link syntheticKeyMap}, and
56
+ * in this module for that reason: "what identifies one row" is one question,
57
+ * and answering it in two places is how a compound selector comes to be
58
+ * accepted by the name and refused by its members (0.72.0 fixed exactly that).
59
+ * The difference is only the arity: a synthetic SELECTOR needs two or more
60
+ * columns to have a joined name, while a single-column unique identifies a row
61
+ * perfectly well.
62
+ */
63
+ /**
64
+ * Throw unless `where` identifies a single row. The refusal for `findUnique` on
65
+ * EVERY engine, message included.
66
+ *
67
+ * Shared rather than written twice because `PowqlInterface` is a parallel
68
+ * implementation: two copies of a rule this specific (which sources count as
69
+ * unique, whether a null identifies, which keys the message lists) is how two
70
+ * engines come to disagree about whether a query is valid, which is the exact
71
+ * divergence 0.64.0 and 0.72.0 were both spent on.
72
+ *
73
+ * The message lists the keys that WOULD work, because the fix is almost always
74
+ * one of them and a caller cannot be expected to know which columns the
75
+ * database considers unique. A table with no unique key at all gets its own
76
+ * sentence: no `where` satisfies this, and "name a unique key" is advice that
77
+ * person cannot take.
78
+ */
79
+ export declare function assertWhereIdentifiesOneRow(meta: TableMetadata, table: string, where: Record<string, unknown> | undefined): void;
80
+ export declare function uniqueKeyNames(meta: TableMetadata): string[][];
81
+ /**
82
+ * True when `where` pins every column of at least one unique key to a single
83
+ * value, so the row it names is the row it gets.
84
+ *
85
+ * Deliberately reads only the TOP LEVEL of the user's where. A unique key
86
+ * buried inside an `OR` does not identify a row (the other branch matches
87
+ * whatever it matches), and one inside an `AND` array is a shape nobody writes
88
+ * for a lookup by identity. Extra predicates alongside the key are fine: they
89
+ * can only narrow a set that already holds at most one row.
90
+ *
91
+ * A NULL is not an identity. `WHERE email IS NULL` matches every row whose
92
+ * email is null, which a UNIQUE constraint permits any number of, so a null
93
+ * value satisfies no key here even on a unique column.
94
+ */
95
+ export declare function whereIdentifiesOneRow(meta: TableMetadata, where: Record<string, unknown>): boolean;