turbine-orm 0.64.1 → 0.65.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/dist/client.js CHANGED
@@ -1025,7 +1025,7 @@ export class TurbineClient {
1025
1025
  if (value === undefined)
1026
1026
  return undefined;
1027
1027
  if (value !== 'null' && value !== 'preserve') {
1028
- throw new ValidationError(`Invalid temporalInfinity: ${JSON.stringify(value)}. Expected 'preserve' (default: read a Postgres ` +
1028
+ throw new ValidationError(`[turbine] Invalid temporalInfinity: ${JSON.stringify(value)}. Expected 'preserve' (default: read a Postgres ` +
1029
1029
  'temporal `infinity` as the JS number `Infinity` / `-Infinity`, which round-trips through a write ' +
1030
1030
  "but breaks the declared `Date` type) or 'null' (read it as null, which serializes cleanly but " +
1031
1031
  'makes it indistinguishable from a stored NULL, so a read-modify-write destroys the value).');
package/dist/dialect.js CHANGED
@@ -80,7 +80,10 @@ export const postgresDialect = {
80
80
  },
81
81
  buildBulkInsertStatement(input) {
82
82
  if (!input.columnArrayTypes || input.columnArrayTypes.length !== input.columns.length) {
83
- throw new ValidationError('PostgreSQL bulk insert requires one array type per column');
83
+ throw new ValidationError(`[turbine] createMany bulk insert into "${input.table}": columnArrayTypes must supply one UNNEST cast ` +
84
+ `per column, got ${input.columnArrayTypes?.length ?? 0} for ${input.columns.length} columns ` +
85
+ `(${input.columns.join(', ')}). Schema metadata is missing a pgType for at least one column; ` +
86
+ 'regenerate it with `npx turbine generate`.');
84
87
  }
85
88
  // Row-major form: required when a target column is itself array-typed,
86
89
  // because the UNNEST transpose below flattens nested arrays (see
package/dist/errors.d.ts CHANGED
@@ -29,6 +29,8 @@ export type TurbineErrorCode = (typeof TurbineErrorCode)[keyof typeof TurbineErr
29
29
  /** Base error class for all Turbine errors */
30
30
  export declare class TurbineError extends Error {
31
31
  readonly code: TurbineErrorCode;
32
+ /** Docs page for this code, e.g. `https://turbineorm.dev/errors#e003`. */
33
+ readonly docsUrl: string;
32
34
  constructor(code: TurbineErrorCode, message: string, options?: {
33
35
  cause?: unknown;
34
36
  });
package/dist/errors.js CHANGED
@@ -25,23 +25,44 @@ export const TurbineErrorCode = {
25
25
  UNSUPPORTED_FEATURE: 'TURBINE_E017',
26
26
  READ_ONLY: 'TURBINE_E018',
27
27
  };
28
+ /**
29
+ * The docs page anchor for a code: `TURBINE_E003` -> `.../errors#e003`.
30
+ *
31
+ * Every code has a row on that page (enforced by a unit test that reads the
32
+ * MDX source, so a new code cannot ship without its docs entry). The URL is
33
+ * carried both in the message and as {@link TurbineError.docsUrl} so
34
+ * structured sinks (Sentry, pino) get it without parsing text.
35
+ */
36
+ function docsUrlForCode(code) {
37
+ return `https://turbineorm.dev/errors#${code.slice('TURBINE_'.length).toLowerCase()}`;
38
+ }
28
39
  /**
29
40
  * Prefix a human message with its stable error code so logs are greppable
30
- * without requiring structured field access. Idempotent if the message already
31
- * starts with `[TURBINE_E0NN]`.
41
+ * without requiring structured field access, and suffix it with the docs URL
42
+ * for the code so a log line is one click from its explanation. Idempotent on
43
+ * both ends: a message already starting with `[TURBINE_E0NN]` keeps its tag,
44
+ * and one already carrying THIS code's link (a same-code re-wrap) does not
45
+ * gain a second. The check is code-specific on purpose: a message embedding a
46
+ * DIFFERENT code's error text (say an E003 wrapped into an E017) still gets
47
+ * its own code's link appended, so the trailing link always agrees with
48
+ * `.docsUrl` and the outermost code.
49
+ *
50
+ * STABILITY.md declares message TEXT non-contract (only the code tag is), so
51
+ * adding the suffix is not a breaking change; branch on `err.code`, never on
52
+ * the message.
32
53
  */
33
54
  function formatErrorMessage(code, message) {
34
55
  const tag = `[${code}]`;
35
- if (message.startsWith(tag))
36
- return message;
56
+ const link = message.includes(docsUrlForCode(code)) ? '' : ` (${docsUrlForCode(code)})`;
37
57
  // Empty message → just the code (defensive; callers always pass text today).
38
- if (!message)
39
- return tag;
40
- return `${tag} ${message}`;
58
+ const body = message.startsWith(tag) ? message : message ? `${tag} ${message}` : tag;
59
+ return `${body}${link}`;
41
60
  }
42
61
  /** Base error class for all Turbine errors */
43
62
  export class TurbineError extends Error {
44
63
  code;
64
+ /** Docs page for this code, e.g. `https://turbineorm.dev/errors#e003`. */
65
+ docsUrl;
45
66
  constructor(code, message, options) {
46
67
  // The cause is redacted in 'safe' mode (see redactCauseForMode). Only pass
47
68
  // an options object through when the caller actually supplied a `cause`
@@ -52,6 +73,7 @@ export class TurbineError extends Error {
52
73
  super(formatErrorMessage(code, message), opts);
53
74
  this.name = 'TurbineError';
54
75
  this.code = code;
76
+ this.docsUrl = docsUrlForCode(code);
55
77
  }
56
78
  }
57
79
  let errorMessageMode = 'safe';
package/dist/mysql.js CHANGED
@@ -480,6 +480,25 @@ export const mysqlDialect = {
480
480
  castAggregate(expr, target) {
481
481
  return `CAST(${expr} AS ${target === 'int' ? 'SIGNED' : 'DECIMAL(65,30)'})`;
482
482
  },
483
+ // MySQL has no bare OFFSET: the grammar requires a LIMIT for OFFSET to
484
+ // attach to, so `offset` without `limit` (valid on Postgres, which the
485
+ // default path is written for) is a syntax error here. The MySQL manual's
486
+ // own idiom for "from this offset to the end" is a LIMIT of
487
+ // 18446744073709551615 (2^64-1). The other shapes emit byte-identically to
488
+ // the default path. Values arrive as inlined validated-integer literals
489
+ // (inlineLimitOffset), so this only concatenates text the builder already
490
+ // vetted.
491
+ buildLimitOffset(input) {
492
+ const { limitPlaceholder, offsetPlaceholder } = input;
493
+ if (limitPlaceholder === undefined && offsetPlaceholder === undefined)
494
+ return '';
495
+ if (limitPlaceholder === undefined)
496
+ return ` LIMIT 18446744073709551615 OFFSET ${offsetPlaceholder}`;
497
+ let s = ` LIMIT ${limitPlaceholder}`;
498
+ if (offsetPlaceholder !== undefined)
499
+ s += ` OFFSET ${offsetPlaceholder}`;
500
+ return s;
501
+ },
483
502
  // No array params in MySQL. JSON_TABLE expands a single JSON-array param into a
484
503
  // row set, keeping ONE placeholder (so the SQL cache stays valid regardless of
485
504
  // list length) and handling the empty-list case (zero rows). MySQL coerces the
package/dist/observe.js CHANGED
@@ -154,7 +154,9 @@ export class ObserveEngine {
154
154
  stopped = false;
155
155
  constructor(config) {
156
156
  if (!config.sink && !config.connectionString) {
157
- throw new ValidationError('ObserveEngine requires either a connectionString or a sink');
157
+ throw new ValidationError('[turbine] ObserveEngine: neither `connectionString` nor `sink` was provided, so there is nowhere to ' +
158
+ 'flush metrics. Pass `connectionString` (a separate metrics database URL, often TURBINE_OBSERVE_URL) ' +
159
+ 'or a custom `sink` implementing ObserveSink.');
158
160
  }
159
161
  this.sink =
160
162
  config.sink ??
package/dist/powql.js CHANGED
@@ -45,7 +45,7 @@ import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/fil
45
45
  // are unlocked ONLY by the UNSAFE symbol, on this engine exactly as on the SQL
46
46
  // engines, so a spread request body cannot turn either on here either.
47
47
  import { assertDirectionToken, resolveUnsafeFlag, UNSAFE } from './query/types.js';
48
- import { escapeLike, ownLookup, relationInProjectionMessage } from './query/utils.js';
48
+ import { escapeLike, ownLookup, relationInProjectionMessage, selectNamesNothingMessage, selectOmitExclusiveMessage, } from './query/utils.js';
49
49
  import { assertJsonFilterKeys, jsonStringEntries } from './query/where.js';
50
50
  import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
51
51
  /**
@@ -887,6 +887,23 @@ export class PowqlInterface {
887
887
  return this.column(field).name;
888
888
  }
889
889
  projectedColumns(select, omit, includePii) {
890
+ // Same two shape refusals as `resolveProjection` on the SQL engines, with
891
+ // the shared messages, and for a live reason here: this path used to
892
+ // APPLY select-minus-omit while the SQL engines ignored the `omit` half,
893
+ // so one query meant different columns depending on the backend. And a
894
+ // zero-key/all-false select used to fall through to the DEFAULT
895
+ // projection here while the SQL engines emitted broken or empty rows;
896
+ // both shapes are ambiguous and now refused identically on every engine.
897
+ // Presence-decided, exactly like resolveProjection (see its comment for
898
+ // the strategy-flip this prevents).
899
+ if (select) {
900
+ if (!Object.values(select).some(Boolean)) {
901
+ throw new ValidationError(selectNamesNothingMessage(this.table));
902
+ }
903
+ if (omit && Object.values(omit).some(Boolean)) {
904
+ throw new ValidationError(selectOmitExclusiveMessage(this.table));
905
+ }
906
+ }
890
907
  const pk = new Set(this.meta.primaryKey);
891
908
  let cols = this.meta.columns.map((c) => c.name);
892
909
  const hasSelect = select && Object.keys(select).length;
@@ -1444,6 +1461,18 @@ export class PowqlInterface {
1444
1461
  // stitching (the join path already gets this for free via `__tpk`).
1445
1462
  const userSelect = options.select;
1446
1463
  const userOmit = options.omit;
1464
+ // The RAW shape rules, before the force-add below: the forced key makes
1465
+ // an all-falsy select look populated to the child's projectedColumns,
1466
+ // which would accept here what the nested-projection path refuses. Same
1467
+ // messages as the SQL engines' assertProjectionShape, same reason.
1468
+ if (userSelect) {
1469
+ if (!Object.values(userSelect).some(Boolean)) {
1470
+ throw new ValidationError(selectNamesNothingMessage(targetMeta.name));
1471
+ }
1472
+ if (userOmit && Object.values(userOmit).some(Boolean)) {
1473
+ throw new ValidationError(selectOmitExclusiveMessage(targetMeta.name));
1474
+ }
1475
+ }
1447
1476
  const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
1448
1477
  let fetchOptions = options;
1449
1478
  if (!fkProjected) {
@@ -12,7 +12,7 @@ import { UnsupportedFeatureError, ValidationError } from '../errors.js';
12
12
  import { snakeToCamel } from '../schema.js';
13
13
  import { isJsonPathOrderBy, isUnmatchedPlainObject, isVectorOrderBy, isWhereOperator, normalizeOrderBy, orderByEntries, } from './filters.js';
14
14
  import { assertOrderDirection, resolveSkipGlobalFilters, resolveUnsafeFlag } from './types.js';
15
- import { isTemporalInfinity, ownLookup } from './utils.js';
15
+ import { isTemporalInfinity, ownLookup, unknownFieldMessage } from './utils.js';
16
16
  import * as whereMod from './where.js';
17
17
  /**
18
18
  * Enforce the PII contract on the aggregate surface. A PII-tagged
@@ -48,7 +48,7 @@ export function buildGroupBy(qi, args) {
48
48
  if (meta) {
49
49
  for (const key of args.by) {
50
50
  if (typeof key === 'string' && !(key in meta.columnMap)) {
51
- throw new ValidationError(`Unknown column "${key}" in groupBy for table "${qi.table}"`);
51
+ throw new ValidationError(unknownFieldMessage(qi.table, key, meta));
52
52
  }
53
53
  }
54
54
  }
@@ -785,7 +785,7 @@ export function buildAggregate(qi, args) {
785
785
  if (group && typeof group === 'object') {
786
786
  for (const key of Object.keys(group)) {
787
787
  if (!(key in meta.columnMap)) {
788
- throw new ValidationError(`Unknown column "${key}" in aggregate for table "${qi.table}"`);
788
+ throw new ValidationError(unknownFieldMessage(qi.table, key, meta));
789
789
  }
790
790
  }
791
791
  }
@@ -796,7 +796,7 @@ export function buildAggregate(qi, args) {
796
796
  if (key === '_all')
797
797
  continue;
798
798
  if (!(key in meta.columnMap)) {
799
- throw new ValidationError(`Unknown column "${key}" in aggregate for table "${qi.table}"`);
799
+ throw new ValidationError(unknownFieldMessage(qi.table, key, meta));
800
800
  }
801
801
  }
802
802
  }
@@ -146,6 +146,16 @@ export declare function defaultProjectionFields(meta: TableMetadata, includePii:
146
146
  * keys) so a caller's `select: { title: true }` on a relation still stitches even
147
147
  * though the FK was not requested, and the FK never appears in the output.
148
148
  */
149
+ /**
150
+ * The two projection SHAPE rules from `resolveProjection`, checked on the RAW
151
+ * caller args BEFORE `includeKeysForBatching` adjusts them. The adjustment
152
+ * force-adds correlation keys to a `select`, so a shape that is invalid as
153
+ * written (an all-falsy select, or select + omit together) can look valid
154
+ * after it, and the batched plan would then accept a query the join plan
155
+ * refuses, with 'auto' picking between them on data. Same messages as the
156
+ * resolver so the two strategies refuse identically, word for word.
157
+ */
158
+ export declare function assertProjectionShape(table: string, select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined): void;
149
159
  export declare function includeKeysForBatching(select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[],
150
160
  /**
151
161
  * The default projection for this table when it is NOT `select`/`omit`-driven:
@@ -56,7 +56,7 @@
56
56
  import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
57
57
  import { normalizeKeyColumns } from '../schema.js';
58
58
  import { isRelationPickOrderBy, sortedEntries } from './filters.js';
59
- import { ownLookup } from './utils.js';
59
+ import { ownLookup, selectNamesNothingMessage, selectOmitExclusiveMessage } from './utils.js';
60
60
  /**
61
61
  * Max parent keys per follow-up query. On Postgres the whole key set travels as
62
62
  * ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit, it
@@ -103,6 +103,27 @@ includePii) {
103
103
  * keys) so a caller's `select: { title: true }` on a relation still stitches even
104
104
  * though the FK was not requested, and the FK never appears in the output.
105
105
  */
106
+ /**
107
+ * The two projection SHAPE rules from `resolveProjection`, checked on the RAW
108
+ * caller args BEFORE `includeKeysForBatching` adjusts them. The adjustment
109
+ * force-adds correlation keys to a `select`, so a shape that is invalid as
110
+ * written (an all-falsy select, or select + omit together) can look valid
111
+ * after it, and the batched plan would then accept a query the join plan
112
+ * refuses, with 'auto' picking between them on data. Same messages as the
113
+ * resolver so the two strategies refuse identically, word for word.
114
+ */
115
+ export function assertProjectionShape(table, select, omit) {
116
+ // Array shapes fall through: buildFindMany's resolver has their specific
117
+ // "must be an object" messages, and compiling is where they surface.
118
+ if (!select || Array.isArray(select))
119
+ return;
120
+ if (!Object.values(select).some(Boolean)) {
121
+ throw new ValidationError(selectNamesNothingMessage(table));
122
+ }
123
+ if (omit && !Array.isArray(omit) && Object.values(omit).some(Boolean)) {
124
+ throw new ValidationError(selectOmitExclusiveMessage(table));
125
+ }
126
+ }
106
127
  export function includeKeysForBatching(select, omit, fields,
107
128
  /**
108
129
  * The default projection for this table when it is NOT `select`/`omit`-driven:
@@ -272,12 +293,19 @@ export function rejectNestedPickOrder(withClause) {
272
293
  export async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0, path = [ctx.parentMeta.name]) {
273
294
  if (depth >= MAX_DEPTH)
274
295
  throw new CircularRelationError([...path, '…']);
275
- // Scope-rule parity with the join strategy: validate the whole tree BEFORE
276
- // the empty-parents early return, so accept/reject never depends on data.
296
+ // Scope-rule parity with the join strategy: the whole tree validates even
297
+ // with zero parents, so accept/reject never depends on data. There is
298
+ // DELIBERATELY no `parents.length === 0` early return here: one used to sit
299
+ // below this line, and it skipped relation-NAME resolution and every child
300
+ // compile whenever the base query matched nothing, so a typo'd relation or
301
+ // child select threw on populated data and passed silently on empty. The
302
+ // loaders below all compile their child query before their own
303
+ // data-dependent exits (one SQL string build per relation node; makeChild
304
+ // creates a fresh QueryInterface, so this is NOT a template-cache hit, and
305
+ // nothing executes), which keeps this path's acceptance byte-aligned with
306
+ // the join plan's compile-time validation.
277
307
  if (depth === 0)
278
308
  rejectNestedPickOrder(withClause);
279
- if (parents.length === 0)
280
- return;
281
309
  // Resolve the relations to load in the SAME order the join plan emits their
282
310
  // columns (`sortedEntries` in buildSelectWithRelations), with the reserved
283
311
  // `_count` key last.
@@ -287,7 +315,10 @@ export async function loadRelationsBatched(ctx, parents, withClause, timeout, de
287
315
  continue;
288
316
  const rel = ownLookup(ctx.parentMeta.relations, relName);
289
317
  if (!rel) {
290
- throw new ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
318
+ // RelationError (E005), NOT ValidationError: the join strategy throws
319
+ // E005 for this exact shape (relations.ts), and under 'auto' the two
320
+ // must refuse identically or the error CODE depends on table size.
321
+ throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
291
322
  `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
292
323
  }
293
324
  resolved.push({ relName, rel, options: spec === true ? {} : spec });
@@ -413,11 +444,6 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
413
444
  assertCorrelationKeyProjected(parents, parentKeyField, relName, ctx.parentMeta.name);
414
445
  const keys = uniqueKeys(parents, parentKeyField);
415
446
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
416
- if (keys.length === 0) {
417
- for (const parent of parents)
418
- parent[relName] = single ? null : [];
419
- return;
420
- }
421
447
  // The follow-up must project the child correlation key even if the caller's
422
448
  // select/omit excluded it; strip it back off afterwards so the shape matches join.
423
449
  //
@@ -429,8 +455,26 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
429
455
  // ones did not, so the defect needed a `select` (or an `omit` of the FK) on a
430
456
  // to-many with a to-one inside it, which is why `include` and `join` were both
431
457
  // clean and seventeen rounds of parity capture missed it.
458
+ assertProjectionShape(targetMeta.name, options.select, options.omit);
432
459
  const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
433
460
  const child = ctx.makeChild(rel.to);
461
+ const buildChunk = (chunk) => child.buildFindMany({
462
+ where: mergeChildWhere(options.where, childKeyField, chunk),
463
+ select: proj.select,
464
+ omit: proj.omit,
465
+ orderBy: options.orderBy,
466
+ skipGlobalFilters: ctx.skipGlobalFilters,
467
+ includePii: ctx.includePii,
468
+ });
469
+ // Zero keys (no parents, or every parent's key is NULL): nothing to fetch,
470
+ // but the child query still COMPILES, because compiling is where the
471
+ // caller's select/omit/where/orderBy names are validated and the join plan
472
+ // validates them regardless of data. Skipping this made a typo'd child
473
+ // select throw or pass based on which rows the base query matched. Cost:
474
+ // one SQL string build (the child is a fresh QueryInterface with its own
475
+ // template LRU, so this is a build, not a cache hit); nothing executes.
476
+ if (keys.length === 0)
477
+ buildChunk([]);
434
478
  const chunks = [];
435
479
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
436
480
  chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
@@ -439,20 +483,16 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
439
483
  // whole batch would cap TOTAL children, not children-per-parent. It is applied
440
484
  // client-side per group after stitching (below).
441
485
  const chunkResults = await Promise.all(chunks.map(async (chunk) => {
442
- const deferred = child.buildFindMany({
443
- where: mergeChildWhere(options.where, childKeyField, chunk),
444
- select: proj.select,
445
- omit: proj.omit,
446
- orderBy: options.orderBy,
447
- skipGlobalFilters: ctx.skipGlobalFilters,
448
- includePii: ctx.includePii,
449
- });
486
+ const deferred = buildChunk(chunk);
450
487
  const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
451
488
  return deferred.transform(result);
452
489
  }));
453
490
  const allChildren = chunkResults.flat();
454
- // Recurse for nested `with` BEFORE stripping keys (children carry their own keys).
455
- if (options.with && allChildren.length > 0) {
491
+ // Recurse for nested `with` BEFORE stripping keys (children carry their own
492
+ // keys). Recursion runs even with zero children: it is the validation walk
493
+ // for the deeper levels of the tree (each level compiles its own child
494
+ // query above), so a typo three levels down throws with or without data.
495
+ if (options.with) {
456
496
  await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
457
497
  }
458
498
  // Symmetric to the parent-side assertion above. If the CHILD key were ever
@@ -500,12 +540,11 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
500
540
  const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
501
541
  const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
502
542
  assertCorrelationKeyProjected(parents, parentRefField, relName, ctx.parentMeta.name);
543
+ // No early return on zero parent keys: the code below flows through
544
+ // naturally (zero junction chunks, zero target chunks), and the compile-only
545
+ // build further down still validates the caller's names, same rule as the
546
+ // to-one/to-many loader.
503
547
  const parentKeys = uniqueKeys(parents, parentRefField);
504
- if (parentKeys.length === 0) {
505
- for (const parent of parents)
506
- parent[relName] = [];
507
- return;
508
- }
509
548
  // (1) Junction rows: sourceKeyVal → [targetKeyVal]. Raw SQL through the caller's
510
549
  // executor (the junction table has no relations we need, so no child reader).
511
550
  const targetsBySource = new Map();
@@ -541,28 +580,36 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
541
580
  // Plus the keys the target's own nested relations need, same rule and same
542
581
  // reason as the to-many loader above: this level's PK is not the only key the
543
582
  // recursion below will ask these rows for.
583
+ assertProjectionShape(targetMeta.name, options.select, options.omit);
544
584
  const proj = includeKeysForBatching(options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
545
585
  const child = ctx.makeChild(rel.to);
586
+ const buildTargetChunk = (chunk) => child.buildFindMany({
587
+ where: mergeChildWhere(options.where, targetPkField, chunk),
588
+ select: proj.select,
589
+ omit: proj.omit,
590
+ orderBy: options.orderBy,
591
+ skipGlobalFilters: ctx.skipGlobalFilters,
592
+ includePii: ctx.includePii,
593
+ });
546
594
  const targetVals = [...targetValSet];
595
+ // Compile-only when there is nothing to fetch: validation of the caller's
596
+ // names lives in the build, and it must not depend on whether any junction
597
+ // row matched (same rule as the to-one/to-many loader).
598
+ if (targetVals.length === 0)
599
+ buildTargetChunk([]);
547
600
  const tChunks = [];
548
601
  for (let i = 0; i < targetVals.length; i += MAX_RELATION_KEYS) {
549
602
  tChunks.push(targetVals.slice(i, i + MAX_RELATION_KEYS));
550
603
  }
551
604
  const tResults = await Promise.all(tChunks.map(async (chunk) => {
552
- const deferred = child.buildFindMany({
553
- where: mergeChildWhere(options.where, targetPkField, chunk),
554
- select: proj.select,
555
- omit: proj.omit,
556
- orderBy: options.orderBy,
557
- skipGlobalFilters: ctx.skipGlobalFilters,
558
- includePii: ctx.includePii,
559
- });
605
+ const deferred = buildTargetChunk(chunk);
560
606
  const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
561
607
  return deferred.transform(result);
562
608
  }));
563
609
  const targetsInOrder = tResults.flat();
564
- // Nested `with` on the target rows (before stripping their PK).
565
- if (options.with && targetsInOrder.length > 0) {
610
+ // Nested `with` on the target rows (before stripping their PK). Runs even
611
+ // with zero targets: it is the validation walk for the deeper levels.
612
+ if (options.with) {
566
613
  await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, targetsInOrder, options.with, timeout, depth + 1, [...path, relName]);
567
614
  }
568
615
  const targetByPk = new Map();
@@ -794,10 +841,16 @@ function groupBy(rows, field) {
794
841
  }
795
842
  return map;
796
843
  }
797
- /** Resolve a table's metadata or throw a clear relation error. */
844
+ /**
845
+ * Resolve a table's metadata or throw a clear relation error. E005
846
+ * (RelationError), matching the class the join path throws for its "Unknown
847
+ * relation target" twin in relations.ts: only corrupt/partial metadata can
848
+ * trigger either, but the error CODE must still not depend on which strategy
849
+ * ran (the same rule as the unknown relation NAME above).
850
+ */
798
851
  function requireTable(schema, table, relName) {
799
852
  const meta = schema.tables[table];
800
853
  if (!meta)
801
- throw new ValidationError(`[turbine] Relation "${relName}" targets unknown table "${table}".`);
854
+ throw new RelationError(`[turbine] Unknown relation target "${table}" (relation "${relName}").`);
802
855
  return meta;
803
856
  }
@@ -16,7 +16,7 @@ import { missingIndexForRelation, schemaHasIndexInfo } from '../index-advisor.js
16
16
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
17
17
  import { normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import * as aggMod from './aggregates.js';
19
- import { defaultProjectionFields, includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
19
+ import { assertProjectionShape, defaultProjectionFields, includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
20
20
  import { expandCompoundUniqueWhere } from './compound-unique.js';
21
21
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
22
22
  import * as relationsMod from './relations.js';
@@ -1391,6 +1391,7 @@ export class QueryInterface {
1391
1391
  // refused one step later.
1392
1392
  const includePii = resolveUnsafeFlag(args.includePii, 'includePii');
1393
1393
  const needed = neededParentKeyFields(this.tableMeta, batchedWith);
1394
+ assertProjectionShape(this.table, args.select, args.omit);
1394
1395
  const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, includePii));
1395
1396
  const hasJoin = Object.keys(joinWith).length > 0;
1396
1397
  // Force the residual `with` onto the join plan so the base query never
@@ -1410,9 +1411,11 @@ export class QueryInterface {
1410
1411
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1411
1412
  const rows = deferred.transform(result);
1412
1413
  const entities = single ? (rows ? [rows] : []) : rows;
1413
- if (entities.length > 0) {
1414
- await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, batchedWith, args.timeout);
1415
- }
1414
+ // Unconditionally, even for zero rows: with no parents the loader is a
1415
+ // pure validation walk over the `with` tree (compile-only child builds),
1416
+ // which is what keeps accept/reject identical to the join plan when the
1417
+ // base query matches nothing.
1418
+ await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, batchedWith, args.timeout);
1416
1419
  stripFields(entities, proj.strip);
1417
1420
  return single ? (entities[0] ?? null) : entities;
1418
1421
  }
@@ -1505,9 +1508,9 @@ export class QueryInterface {
1505
1508
  const deferred = this.buildFindMany(baseArgs);
1506
1509
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1507
1510
  const entities = deferred.transform(result);
1508
- if (entities.length > 0) {
1509
- await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, withClause, args.timeout);
1510
- }
1511
+ // Unconditionally, even for zero rows: see the 'auto' path above, the
1512
+ // loader doubles as the compile-time validation walk of the `with` tree.
1513
+ await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, withClause, args.timeout);
1511
1514
  stripFields(entities, strip);
1512
1515
  return entities;
1513
1516
  }
@@ -1518,6 +1521,7 @@ export class QueryInterface {
1518
1521
  */
1519
1522
  prepareBatchedBase(args, withClause) {
1520
1523
  const needed = neededParentKeyFields(this.tableMeta, withClause);
1524
+ assertProjectionShape(this.table, args.select, args.omit);
1521
1525
  const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, resolveUnsafeFlag(args.includePii, 'includePii')));
1522
1526
  const baseArgs = {
1523
1527
  ...args,
@@ -1891,14 +1895,18 @@ export class QueryInterface {
1891
1895
  // Same scope-rule parity as runFindManyBatched: reject before querying.
1892
1896
  rejectNestedPickOrder(withClause);
1893
1897
  const needed = neededParentKeyFields(this.tableMeta, withClause);
1898
+ assertProjectionShape(this.table, args.select, args.omit);
1894
1899
  const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, resolveUnsafeFlag(args.includePii, 'includePii')));
1895
1900
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
1896
1901
  const deferred = this.buildFindUnique(baseArgs);
1897
1902
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1898
1903
  const entity = deferred.transform(result);
1904
+ // A miss still walks the `with` tree (compile-only child builds), so the
1905
+ // same args throw or pass identically whether or not the row exists,
1906
+ // matching the join plan which validates the whole statement up front.
1907
+ await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii, args.forceCustomPlan === true), entity ? [entity] : [], withClause, args.timeout);
1899
1908
  if (!entity)
1900
1909
  return null;
1901
- await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii, args.forceCustomPlan === true), [entity], withClause, args.timeout);
1902
1910
  stripFields([entity], proj.strip);
1903
1911
  return entity;
1904
1912
  }
@@ -18,7 +18,7 @@ import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import { resolveCountRelations } from './batched-loader.js';
19
19
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
20
20
  import { assertDirectionToken, assertOrderDirection } from './types.js';
21
- import { ownLookup, relationInProjectionMessage, resolveColumnName, unknownFieldMessage } from './utils.js';
21
+ import { ownLookup, relationInProjectionMessage, resolveColumnName, selectNamesNothingMessage, selectOmitExclusiveMessage, unknownFieldMessage, } from './utils.js';
22
22
  import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
23
23
  import * as whereMod from './where.js';
24
24
  import * as writesMod from './writes.js';
@@ -75,6 +75,34 @@ function projectionColumn(table, meta, field, clause) {
75
75
  * name handling would mean reimplementing those too.
76
76
  */
77
77
  export function resolveProjection(qi, table, meta, select, omit, includePii) {
78
+ // Projection SHAPE refusals, in order (array shapes fall through to their
79
+ // own, more specific messages below):
80
+ //
81
+ // 1. A `select` naming no fields (empty, or every value false) is refused,
82
+ // not resolved to an empty column list: at the top level that list used
83
+ // to emit `SELECT FROM`, invalid SQL, while a relation quietly returned
84
+ // `[{}]` rows, two silent third outcomes of the kind this function was
85
+ // merged to abolish.
86
+ // 2. `select` + `omit` together is refused, not half-resolved: a narrowed
87
+ // projection minus fields is ambiguous, Prisma refuses the pair, and
88
+ // prisma-compat here already did. Before this check the `select` branch
89
+ // below returned early, so the `omit` names were never validated at all
90
+ // (a typo in the `omit` half passed while the same typo alone threw).
91
+ //
92
+ // Check 1 runs first so both are PRESENCE-decided exactly like the branch
93
+ // below (`if (select)`): a truthiness-decided refusal here flipped verdicts
94
+ // between strategies when the batched loader force-added correlation keys
95
+ // to an all-falsy select (review-caught in 0.65). The batched loader
96
+ // asserts the same two rules on the RAW args (`assertProjectionShape`)
97
+ // before any adjustment, with these same messages.
98
+ if (select && !Array.isArray(select)) {
99
+ if (!Object.values(select).some(Boolean)) {
100
+ throw new ValidationError(selectNamesNothingMessage(table));
101
+ }
102
+ if (omit && !Array.isArray(omit) && Object.values(omit).some(Boolean)) {
103
+ throw new ValidationError(selectOmitExclusiveMessage(table));
104
+ }
105
+ }
78
106
  if (select) {
79
107
  // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
80
108
  // style) instead of the object shape. Object.entries() would iterate the
@@ -467,3 +467,16 @@ export declare function unknownFieldMessage(table: string, field: string, meta:
467
467
  * it. Naming the fix costs one sentence and saves a search.
468
468
  */
469
469
  export declare function relationInProjectionMessage(table: string, field: string, clause: 'select' | 'omit'): string;
470
+ /**
471
+ * The two projection SHAPE refusals (0.65), shared by the SQL engines' single
472
+ * resolver, the batched loader's raw-arg check, and PowDB, so every path that
473
+ * refuses these shapes does so with one message.
474
+ *
475
+ * Both checks look at TRUTHY keys, and the raw-arg check in the batched
476
+ * loader exists because the loader force-adds correlation keys to a `select`
477
+ * before the resolver sees it: evaluated after that adjustment, an all-falsy
478
+ * user `select` looks populated and the verdict flips between strategies,
479
+ * which is exactly the class 0.64/0.65 exist to kill.
480
+ */
481
+ export declare function selectNamesNothingMessage(table: string): string;
482
+ export declare function selectOmitExclusiveMessage(table: string): string;
@@ -876,3 +876,22 @@ export function relationInProjectionMessage(table, field, clause) {
876
876
  : `${head} A relation is only present when you ask for it in \`with\`, so leave it out of \`with\` to leave it` +
877
877
  ' out of the result.';
878
878
  }
879
+ /**
880
+ * The two projection SHAPE refusals (0.65), shared by the SQL engines' single
881
+ * resolver, the batched loader's raw-arg check, and PowDB, so every path that
882
+ * refuses these shapes does so with one message.
883
+ *
884
+ * Both checks look at TRUTHY keys, and the raw-arg check in the batched
885
+ * loader exists because the loader force-adds correlation keys to a `select`
886
+ * before the resolver sees it: evaluated after that adjustment, an all-falsy
887
+ * user `select` looks populated and the verdict flips between strategies,
888
+ * which is exactly the class 0.64/0.65 exist to kill.
889
+ */
890
+ export function selectNamesNothingMessage(table) {
891
+ return (`[turbine] "select" names no fields (on table "${table}"): every value is false or it is empty. ` +
892
+ `Pass at least one field as true, or drop "select" to get the default projection.`);
893
+ }
894
+ export function selectOmitExclusiveMessage(table) {
895
+ return (`[turbine] "select" and "omit" are mutually exclusive (on table "${table}"). ` +
896
+ `A select already lists exactly the fields you want.`);
897
+ }