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.
@@ -1033,7 +1033,7 @@ class TurbineClient {
1033
1033
  if (value === undefined)
1034
1034
  return undefined;
1035
1035
  if (value !== 'null' && value !== 'preserve') {
1036
- throw new errors_js_1.ValidationError(`Invalid temporalInfinity: ${JSON.stringify(value)}. Expected 'preserve' (default: read a Postgres ` +
1036
+ throw new errors_js_1.ValidationError(`[turbine] Invalid temporalInfinity: ${JSON.stringify(value)}. Expected 'preserve' (default: read a Postgres ` +
1037
1037
  'temporal `infinity` as the JS number `Infinity` / `-Infinity`, which round-trips through a write ' +
1038
1038
  "but breaks the declared `Date` type) or 'null' (read it as null, which serializes cleanly but " +
1039
1039
  'makes it indistinguishable from a stored NULL, so a read-modify-write destroys the value).');
@@ -116,7 +116,10 @@ exports.postgresDialect = {
116
116
  },
117
117
  buildBulkInsertStatement(input) {
118
118
  if (!input.columnArrayTypes || input.columnArrayTypes.length !== input.columns.length) {
119
- throw new errors_js_1.ValidationError('PostgreSQL bulk insert requires one array type per column');
119
+ throw new errors_js_1.ValidationError(`[turbine] createMany bulk insert into "${input.table}": columnArrayTypes must supply one UNNEST cast ` +
120
+ `per column, got ${input.columnArrayTypes?.length ?? 0} for ${input.columns.length} columns ` +
121
+ `(${input.columns.join(', ')}). Schema metadata is missing a pgType for at least one column; ` +
122
+ 'regenerate it with `npx turbine generate`.');
120
123
  }
121
124
  // Row-major form: required when a target column is itself array-typed,
122
125
  // because the UNNEST transpose below flattens nested arrays (see
@@ -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
  });
@@ -32,23 +32,44 @@ exports.TurbineErrorCode = {
32
32
  UNSUPPORTED_FEATURE: 'TURBINE_E017',
33
33
  READ_ONLY: 'TURBINE_E018',
34
34
  };
35
+ /**
36
+ * The docs page anchor for a code: `TURBINE_E003` -> `.../errors#e003`.
37
+ *
38
+ * Every code has a row on that page (enforced by a unit test that reads the
39
+ * MDX source, so a new code cannot ship without its docs entry). The URL is
40
+ * carried both in the message and as {@link TurbineError.docsUrl} so
41
+ * structured sinks (Sentry, pino) get it without parsing text.
42
+ */
43
+ function docsUrlForCode(code) {
44
+ return `https://turbineorm.dev/errors#${code.slice('TURBINE_'.length).toLowerCase()}`;
45
+ }
35
46
  /**
36
47
  * Prefix a human message with its stable error code so logs are greppable
37
- * without requiring structured field access. Idempotent if the message already
38
- * starts with `[TURBINE_E0NN]`.
48
+ * without requiring structured field access, and suffix it with the docs URL
49
+ * for the code so a log line is one click from its explanation. Idempotent on
50
+ * both ends: a message already starting with `[TURBINE_E0NN]` keeps its tag,
51
+ * and one already carrying THIS code's link (a same-code re-wrap) does not
52
+ * gain a second. The check is code-specific on purpose: a message embedding a
53
+ * DIFFERENT code's error text (say an E003 wrapped into an E017) still gets
54
+ * its own code's link appended, so the trailing link always agrees with
55
+ * `.docsUrl` and the outermost code.
56
+ *
57
+ * STABILITY.md declares message TEXT non-contract (only the code tag is), so
58
+ * adding the suffix is not a breaking change; branch on `err.code`, never on
59
+ * the message.
39
60
  */
40
61
  function formatErrorMessage(code, message) {
41
62
  const tag = `[${code}]`;
42
- if (message.startsWith(tag))
43
- return message;
63
+ const link = message.includes(docsUrlForCode(code)) ? '' : ` (${docsUrlForCode(code)})`;
44
64
  // Empty message → just the code (defensive; callers always pass text today).
45
- if (!message)
46
- return tag;
47
- return `${tag} ${message}`;
65
+ const body = message.startsWith(tag) ? message : message ? `${tag} ${message}` : tag;
66
+ return `${body}${link}`;
48
67
  }
49
68
  /** Base error class for all Turbine errors */
50
69
  class TurbineError extends Error {
51
70
  code;
71
+ /** Docs page for this code, e.g. `https://turbineorm.dev/errors#e003`. */
72
+ docsUrl;
52
73
  constructor(code, message, options) {
53
74
  // The cause is redacted in 'safe' mode (see redactCauseForMode). Only pass
54
75
  // an options object through when the caller actually supplied a `cause`
@@ -59,6 +80,7 @@ class TurbineError extends Error {
59
80
  super(formatErrorMessage(code, message), opts);
60
81
  this.name = 'TurbineError';
61
82
  this.code = code;
83
+ this.docsUrl = docsUrlForCode(code);
62
84
  }
63
85
  }
64
86
  exports.TurbineError = TurbineError;
package/dist/cjs/mysql.js CHANGED
@@ -491,6 +491,25 @@ exports.mysqlDialect = {
491
491
  castAggregate(expr, target) {
492
492
  return `CAST(${expr} AS ${target === 'int' ? 'SIGNED' : 'DECIMAL(65,30)'})`;
493
493
  },
494
+ // MySQL has no bare OFFSET: the grammar requires a LIMIT for OFFSET to
495
+ // attach to, so `offset` without `limit` (valid on Postgres, which the
496
+ // default path is written for) is a syntax error here. The MySQL manual's
497
+ // own idiom for "from this offset to the end" is a LIMIT of
498
+ // 18446744073709551615 (2^64-1). The other shapes emit byte-identically to
499
+ // the default path. Values arrive as inlined validated-integer literals
500
+ // (inlineLimitOffset), so this only concatenates text the builder already
501
+ // vetted.
502
+ buildLimitOffset(input) {
503
+ const { limitPlaceholder, offsetPlaceholder } = input;
504
+ if (limitPlaceholder === undefined && offsetPlaceholder === undefined)
505
+ return '';
506
+ if (limitPlaceholder === undefined)
507
+ return ` LIMIT 18446744073709551615 OFFSET ${offsetPlaceholder}`;
508
+ let s = ` LIMIT ${limitPlaceholder}`;
509
+ if (offsetPlaceholder !== undefined)
510
+ s += ` OFFSET ${offsetPlaceholder}`;
511
+ return s;
512
+ },
494
513
  // No array params in MySQL. JSON_TABLE expands a single JSON-array param into a
495
514
  // row set, keeping ONE placeholder (so the SQL cache stays valid regardless of
496
515
  // list length) and handling the empty-list case (zero rows). MySQL coerces the
@@ -164,7 +164,9 @@ class ObserveEngine {
164
164
  stopped = false;
165
165
  constructor(config) {
166
166
  if (!config.sink && !config.connectionString) {
167
- throw new errors_js_1.ValidationError('ObserveEngine requires either a connectionString or a sink');
167
+ throw new errors_js_1.ValidationError('[turbine] ObserveEngine: neither `connectionString` nor `sink` was provided, so there is nowhere to ' +
168
+ 'flush metrics. Pass `connectionString` (a separate metrics database URL, often TURBINE_OBSERVE_URL) ' +
169
+ 'or a custom `sink` implementing ObserveSink.');
168
170
  }
169
171
  this.sink =
170
172
  config.sink ??
package/dist/cjs/powql.js CHANGED
@@ -923,6 +923,23 @@ class PowqlInterface {
923
923
  return this.column(field).name;
924
924
  }
925
925
  projectedColumns(select, omit, includePii) {
926
+ // Same two shape refusals as `resolveProjection` on the SQL engines, with
927
+ // the shared messages, and for a live reason here: this path used to
928
+ // APPLY select-minus-omit while the SQL engines ignored the `omit` half,
929
+ // so one query meant different columns depending on the backend. And a
930
+ // zero-key/all-false select used to fall through to the DEFAULT
931
+ // projection here while the SQL engines emitted broken or empty rows;
932
+ // both shapes are ambiguous and now refused identically on every engine.
933
+ // Presence-decided, exactly like resolveProjection (see its comment for
934
+ // the strategy-flip this prevents).
935
+ if (select) {
936
+ if (!Object.values(select).some(Boolean)) {
937
+ throw new errors_js_1.ValidationError((0, utils_js_1.selectNamesNothingMessage)(this.table));
938
+ }
939
+ if (omit && Object.values(omit).some(Boolean)) {
940
+ throw new errors_js_1.ValidationError((0, utils_js_1.selectOmitExclusiveMessage)(this.table));
941
+ }
942
+ }
926
943
  const pk = new Set(this.meta.primaryKey);
927
944
  let cols = this.meta.columns.map((c) => c.name);
928
945
  const hasSelect = select && Object.keys(select).length;
@@ -1480,6 +1497,18 @@ class PowqlInterface {
1480
1497
  // stitching (the join path already gets this for free via `__tpk`).
1481
1498
  const userSelect = options.select;
1482
1499
  const userOmit = options.omit;
1500
+ // The RAW shape rules, before the force-add below: the forced key makes
1501
+ // an all-falsy select look populated to the child's projectedColumns,
1502
+ // which would accept here what the nested-projection path refuses. Same
1503
+ // messages as the SQL engines' assertProjectionShape, same reason.
1504
+ if (userSelect) {
1505
+ if (!Object.values(userSelect).some(Boolean)) {
1506
+ throw new errors_js_1.ValidationError((0, utils_js_1.selectNamesNothingMessage)(targetMeta.name));
1507
+ }
1508
+ if (userOmit && Object.values(userOmit).some(Boolean)) {
1509
+ throw new errors_js_1.ValidationError((0, utils_js_1.selectOmitExclusiveMessage)(targetMeta.name));
1510
+ }
1511
+ }
1483
1512
  const fkProjected = userSelect ? Boolean(userSelect[childKeyField]) : userOmit ? !userOmit[childKeyField] : true;
1484
1513
  let fetchOptions = options;
1485
1514
  if (!fkProjected) {
@@ -91,7 +91,7 @@ function buildGroupBy(qi, args) {
91
91
  if (meta) {
92
92
  for (const key of args.by) {
93
93
  if (typeof key === 'string' && !(key in meta.columnMap)) {
94
- throw new errors_js_1.ValidationError(`Unknown column "${key}" in groupBy for table "${qi.table}"`);
94
+ throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(qi.table, key, meta));
95
95
  }
96
96
  }
97
97
  }
@@ -828,7 +828,7 @@ function buildAggregate(qi, args) {
828
828
  if (group && typeof group === 'object') {
829
829
  for (const key of Object.keys(group)) {
830
830
  if (!(key in meta.columnMap)) {
831
- throw new errors_js_1.ValidationError(`Unknown column "${key}" in aggregate for table "${qi.table}"`);
831
+ throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(qi.table, key, meta));
832
832
  }
833
833
  }
834
834
  }
@@ -839,7 +839,7 @@ function buildAggregate(qi, args) {
839
839
  if (key === '_all')
840
840
  continue;
841
841
  if (!(key in meta.columnMap)) {
842
- throw new errors_js_1.ValidationError(`Unknown column "${key}" in aggregate for table "${qi.table}"`);
842
+ throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(qi.table, key, meta));
843
843
  }
844
844
  }
845
845
  }
@@ -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,6 +56,7 @@
56
56
  */
57
57
  Object.defineProperty(exports, "__esModule", { value: true });
58
58
  exports.defaultProjectionFields = defaultProjectionFields;
59
+ exports.assertProjectionShape = assertProjectionShape;
59
60
  exports.includeKeysForBatching = includeKeysForBatching;
60
61
  exports.stripFields = stripFields;
61
62
  exports.neededParentKeyFields = neededParentKeyFields;
@@ -112,6 +113,27 @@ includePii) {
112
113
  * keys) so a caller's `select: { title: true }` on a relation still stitches even
113
114
  * though the FK was not requested, and the FK never appears in the output.
114
115
  */
116
+ /**
117
+ * The two projection SHAPE rules from `resolveProjection`, checked on the RAW
118
+ * caller args BEFORE `includeKeysForBatching` adjusts them. The adjustment
119
+ * force-adds correlation keys to a `select`, so a shape that is invalid as
120
+ * written (an all-falsy select, or select + omit together) can look valid
121
+ * after it, and the batched plan would then accept a query the join plan
122
+ * refuses, with 'auto' picking between them on data. Same messages as the
123
+ * resolver so the two strategies refuse identically, word for word.
124
+ */
125
+ function assertProjectionShape(table, select, omit) {
126
+ // Array shapes fall through: buildFindMany's resolver has their specific
127
+ // "must be an object" messages, and compiling is where they surface.
128
+ if (!select || Array.isArray(select))
129
+ return;
130
+ if (!Object.values(select).some(Boolean)) {
131
+ throw new errors_js_1.ValidationError((0, utils_js_1.selectNamesNothingMessage)(table));
132
+ }
133
+ if (omit && !Array.isArray(omit) && Object.values(omit).some(Boolean)) {
134
+ throw new errors_js_1.ValidationError((0, utils_js_1.selectOmitExclusiveMessage)(table));
135
+ }
136
+ }
115
137
  function includeKeysForBatching(select, omit, fields,
116
138
  /**
117
139
  * The default projection for this table when it is NOT `select`/`omit`-driven:
@@ -281,12 +303,19 @@ function rejectNestedPickOrder(withClause) {
281
303
  async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0, path = [ctx.parentMeta.name]) {
282
304
  if (depth >= MAX_DEPTH)
283
305
  throw new errors_js_1.CircularRelationError([...path, '…']);
284
- // Scope-rule parity with the join strategy: validate the whole tree BEFORE
285
- // the empty-parents early return, so accept/reject never depends on data.
306
+ // Scope-rule parity with the join strategy: the whole tree validates even
307
+ // with zero parents, so accept/reject never depends on data. There is
308
+ // DELIBERATELY no `parents.length === 0` early return here: one used to sit
309
+ // below this line, and it skipped relation-NAME resolution and every child
310
+ // compile whenever the base query matched nothing, so a typo'd relation or
311
+ // child select threw on populated data and passed silently on empty. The
312
+ // loaders below all compile their child query before their own
313
+ // data-dependent exits (one SQL string build per relation node; makeChild
314
+ // creates a fresh QueryInterface, so this is NOT a template-cache hit, and
315
+ // nothing executes), which keeps this path's acceptance byte-aligned with
316
+ // the join plan's compile-time validation.
286
317
  if (depth === 0)
287
318
  rejectNestedPickOrder(withClause);
288
- if (parents.length === 0)
289
- return;
290
319
  // Resolve the relations to load in the SAME order the join plan emits their
291
320
  // columns (`sortedEntries` in buildSelectWithRelations), with the reserved
292
321
  // `_count` key last.
@@ -296,7 +325,10 @@ async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0
296
325
  continue;
297
326
  const rel = (0, utils_js_1.ownLookup)(ctx.parentMeta.relations, relName);
298
327
  if (!rel) {
299
- throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
328
+ // RelationError (E005), NOT ValidationError: the join strategy throws
329
+ // E005 for this exact shape (relations.ts), and under 'auto' the two
330
+ // must refuse identically or the error CODE depends on table size.
331
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
300
332
  `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
301
333
  }
302
334
  resolved.push({ relName, rel, options: spec === true ? {} : spec });
@@ -422,11 +454,6 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
422
454
  assertCorrelationKeyProjected(parents, parentKeyField, relName, ctx.parentMeta.name);
423
455
  const keys = uniqueKeys(parents, parentKeyField);
424
456
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
425
- if (keys.length === 0) {
426
- for (const parent of parents)
427
- parent[relName] = single ? null : [];
428
- return;
429
- }
430
457
  // The follow-up must project the child correlation key even if the caller's
431
458
  // select/omit excluded it; strip it back off afterwards so the shape matches join.
432
459
  //
@@ -438,8 +465,26 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
438
465
  // ones did not, so the defect needed a `select` (or an `omit` of the FK) on a
439
466
  // to-many with a to-one inside it, which is why `include` and `join` were both
440
467
  // clean and seventeen rounds of parity capture missed it.
468
+ assertProjectionShape(targetMeta.name, options.select, options.omit);
441
469
  const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
442
470
  const child = ctx.makeChild(rel.to);
471
+ const buildChunk = (chunk) => child.buildFindMany({
472
+ where: mergeChildWhere(options.where, childKeyField, chunk),
473
+ select: proj.select,
474
+ omit: proj.omit,
475
+ orderBy: options.orderBy,
476
+ skipGlobalFilters: ctx.skipGlobalFilters,
477
+ includePii: ctx.includePii,
478
+ });
479
+ // Zero keys (no parents, or every parent's key is NULL): nothing to fetch,
480
+ // but the child query still COMPILES, because compiling is where the
481
+ // caller's select/omit/where/orderBy names are validated and the join plan
482
+ // validates them regardless of data. Skipping this made a typo'd child
483
+ // select throw or pass based on which rows the base query matched. Cost:
484
+ // one SQL string build (the child is a fresh QueryInterface with its own
485
+ // template LRU, so this is a build, not a cache hit); nothing executes.
486
+ if (keys.length === 0)
487
+ buildChunk([]);
443
488
  const chunks = [];
444
489
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
445
490
  chunks.push(keys.slice(i, i + MAX_RELATION_KEYS));
@@ -448,20 +493,16 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
448
493
  // whole batch would cap TOTAL children, not children-per-parent. It is applied
449
494
  // client-side per group after stitching (below).
450
495
  const chunkResults = await Promise.all(chunks.map(async (chunk) => {
451
- const deferred = child.buildFindMany({
452
- where: mergeChildWhere(options.where, childKeyField, chunk),
453
- select: proj.select,
454
- omit: proj.omit,
455
- orderBy: options.orderBy,
456
- skipGlobalFilters: ctx.skipGlobalFilters,
457
- includePii: ctx.includePii,
458
- });
496
+ const deferred = buildChunk(chunk);
459
497
  const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
460
498
  return deferred.transform(result);
461
499
  }));
462
500
  const allChildren = chunkResults.flat();
463
- // Recurse for nested `with` BEFORE stripping keys (children carry their own keys).
464
- if (options.with && allChildren.length > 0) {
501
+ // Recurse for nested `with` BEFORE stripping keys (children carry their own
502
+ // keys). Recursion runs even with zero children: it is the validation walk
503
+ // for the deeper levels of the tree (each level compiles its own child
504
+ // query above), so a typo three levels down throws with or without data.
505
+ if (options.with) {
465
506
  await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
466
507
  }
467
508
  // Symmetric to the parent-side assertion above. If the CHILD key were ever
@@ -509,12 +550,11 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
509
550
  const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
510
551
  const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
511
552
  assertCorrelationKeyProjected(parents, parentRefField, relName, ctx.parentMeta.name);
553
+ // No early return on zero parent keys: the code below flows through
554
+ // naturally (zero junction chunks, zero target chunks), and the compile-only
555
+ // build further down still validates the caller's names, same rule as the
556
+ // to-one/to-many loader.
512
557
  const parentKeys = uniqueKeys(parents, parentRefField);
513
- if (parentKeys.length === 0) {
514
- for (const parent of parents)
515
- parent[relName] = [];
516
- return;
517
- }
518
558
  // (1) Junction rows: sourceKeyVal → [targetKeyVal]. Raw SQL through the caller's
519
559
  // executor (the junction table has no relations we need, so no child reader).
520
560
  const targetsBySource = new Map();
@@ -550,28 +590,36 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
550
590
  // Plus the keys the target's own nested relations need, same rule and same
551
591
  // reason as the to-many loader above: this level's PK is not the only key the
552
592
  // recursion below will ask these rows for.
593
+ assertProjectionShape(targetMeta.name, options.select, options.omit);
553
594
  const proj = includeKeysForBatching(options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
554
595
  const child = ctx.makeChild(rel.to);
596
+ const buildTargetChunk = (chunk) => child.buildFindMany({
597
+ where: mergeChildWhere(options.where, targetPkField, chunk),
598
+ select: proj.select,
599
+ omit: proj.omit,
600
+ orderBy: options.orderBy,
601
+ skipGlobalFilters: ctx.skipGlobalFilters,
602
+ includePii: ctx.includePii,
603
+ });
555
604
  const targetVals = [...targetValSet];
605
+ // Compile-only when there is nothing to fetch: validation of the caller's
606
+ // names lives in the build, and it must not depend on whether any junction
607
+ // row matched (same rule as the to-one/to-many loader).
608
+ if (targetVals.length === 0)
609
+ buildTargetChunk([]);
556
610
  const tChunks = [];
557
611
  for (let i = 0; i < targetVals.length; i += MAX_RELATION_KEYS) {
558
612
  tChunks.push(targetVals.slice(i, i + MAX_RELATION_KEYS));
559
613
  }
560
614
  const tResults = await Promise.all(tChunks.map(async (chunk) => {
561
- const deferred = child.buildFindMany({
562
- where: mergeChildWhere(options.where, targetPkField, chunk),
563
- select: proj.select,
564
- omit: proj.omit,
565
- orderBy: options.orderBy,
566
- skipGlobalFilters: ctx.skipGlobalFilters,
567
- includePii: ctx.includePii,
568
- });
615
+ const deferred = buildTargetChunk(chunk);
569
616
  const result = await ctx.exec(deferred.sql, deferred.params, deferred.preparedName);
570
617
  return deferred.transform(result);
571
618
  }));
572
619
  const targetsInOrder = tResults.flat();
573
- // Nested `with` on the target rows (before stripping their PK).
574
- if (options.with && targetsInOrder.length > 0) {
620
+ // Nested `with` on the target rows (before stripping their PK). Runs even
621
+ // with zero targets: it is the validation walk for the deeper levels.
622
+ if (options.with) {
575
623
  await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, targetsInOrder, options.with, timeout, depth + 1, [...path, relName]);
576
624
  }
577
625
  const targetByPk = new Map();
@@ -803,10 +851,16 @@ function groupBy(rows, field) {
803
851
  }
804
852
  return map;
805
853
  }
806
- /** Resolve a table's metadata or throw a clear relation error. */
854
+ /**
855
+ * Resolve a table's metadata or throw a clear relation error. E005
856
+ * (RelationError), matching the class the join path throws for its "Unknown
857
+ * relation target" twin in relations.ts: only corrupt/partial metadata can
858
+ * trigger either, but the error CODE must still not depend on which strategy
859
+ * ran (the same rule as the unknown relation NAME above).
860
+ */
807
861
  function requireTable(schema, table, relName) {
808
862
  const meta = schema.tables[table];
809
863
  if (!meta)
810
- throw new errors_js_1.ValidationError(`[turbine] Relation "${relName}" targets unknown table "${table}".`);
864
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${table}" (relation "${relName}").`);
811
865
  return meta;
812
866
  }
@@ -1428,6 +1428,7 @@ class QueryInterface {
1428
1428
  // refused one step later.
1429
1429
  const includePii = (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii');
1430
1430
  const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, batchedWith);
1431
+ (0, batched_loader_js_1.assertProjectionShape)(this.table, args.select, args.omit);
1431
1432
  const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, includePii));
1432
1433
  const hasJoin = Object.keys(joinWith).length > 0;
1433
1434
  // Force the residual `with` onto the join plan so the base query never
@@ -1447,9 +1448,11 @@ class QueryInterface {
1447
1448
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1448
1449
  const rows = deferred.transform(result);
1449
1450
  const entities = single ? (rows ? [rows] : []) : rows;
1450
- if (entities.length > 0) {
1451
- await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, batchedWith, args.timeout);
1452
- }
1451
+ // Unconditionally, even for zero rows: with no parents the loader is a
1452
+ // pure validation walk over the `with` tree (compile-only child builds),
1453
+ // which is what keeps accept/reject identical to the join plan when the
1454
+ // base query matches nothing.
1455
+ await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, batchedWith, args.timeout);
1453
1456
  (0, batched_loader_js_1.stripFields)(entities, proj.strip);
1454
1457
  return single ? (entities[0] ?? null) : entities;
1455
1458
  }
@@ -1542,9 +1545,9 @@ class QueryInterface {
1542
1545
  const deferred = this.buildFindMany(baseArgs);
1543
1546
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1544
1547
  const entities = deferred.transform(result);
1545
- if (entities.length > 0) {
1546
- await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, withClause, args.timeout);
1547
- }
1548
+ // Unconditionally, even for zero rows: see the 'auto' path above, the
1549
+ // loader doubles as the compile-time validation walk of the `with` tree.
1550
+ await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, withClause, args.timeout);
1548
1551
  (0, batched_loader_js_1.stripFields)(entities, strip);
1549
1552
  return entities;
1550
1553
  }
@@ -1555,6 +1558,7 @@ class QueryInterface {
1555
1558
  */
1556
1559
  prepareBatchedBase(args, withClause) {
1557
1560
  const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
1561
+ (0, batched_loader_js_1.assertProjectionShape)(this.table, args.select, args.omit);
1558
1562
  const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii')));
1559
1563
  const baseArgs = {
1560
1564
  ...args,
@@ -1928,14 +1932,18 @@ class QueryInterface {
1928
1932
  // Same scope-rule parity as runFindManyBatched: reject before querying.
1929
1933
  (0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
1930
1934
  const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
1935
+ (0, batched_loader_js_1.assertProjectionShape)(this.table, args.select, args.omit);
1931
1936
  const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii')));
1932
1937
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
1933
1938
  const deferred = this.buildFindUnique(baseArgs);
1934
1939
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1935
1940
  const entity = deferred.transform(result);
1941
+ // A miss still walks the `with` tree (compile-only child builds), so the
1942
+ // same args throw or pass identically whether or not the row exists,
1943
+ // matching the join plan which validates the whole statement up front.
1944
+ await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii, args.forceCustomPlan === true), entity ? [entity] : [], withClause, args.timeout);
1936
1945
  if (!entity)
1937
1946
  return null;
1938
- await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii, args.forceCustomPlan === true), [entity], withClause, args.timeout);
1939
1947
  (0, batched_loader_js_1.stripFields)([entity], proj.strip);
1940
1948
  return entity;
1941
1949
  }
@@ -153,6 +153,34 @@ function projectionColumn(table, meta, field, clause) {
153
153
  * name handling would mean reimplementing those too.
154
154
  */
155
155
  function resolveProjection(qi, table, meta, select, omit, includePii) {
156
+ // Projection SHAPE refusals, in order (array shapes fall through to their
157
+ // own, more specific messages below):
158
+ //
159
+ // 1. A `select` naming no fields (empty, or every value false) is refused,
160
+ // not resolved to an empty column list: at the top level that list used
161
+ // to emit `SELECT FROM`, invalid SQL, while a relation quietly returned
162
+ // `[{}]` rows, two silent third outcomes of the kind this function was
163
+ // merged to abolish.
164
+ // 2. `select` + `omit` together is refused, not half-resolved: a narrowed
165
+ // projection minus fields is ambiguous, Prisma refuses the pair, and
166
+ // prisma-compat here already did. Before this check the `select` branch
167
+ // below returned early, so the `omit` names were never validated at all
168
+ // (a typo in the `omit` half passed while the same typo alone threw).
169
+ //
170
+ // Check 1 runs first so both are PRESENCE-decided exactly like the branch
171
+ // below (`if (select)`): a truthiness-decided refusal here flipped verdicts
172
+ // between strategies when the batched loader force-added correlation keys
173
+ // to an all-falsy select (review-caught in 0.65). The batched loader
174
+ // asserts the same two rules on the RAW args (`assertProjectionShape`)
175
+ // before any adjustment, with these same messages.
176
+ if (select && !Array.isArray(select)) {
177
+ if (!Object.values(select).some(Boolean)) {
178
+ throw new errors_js_1.ValidationError((0, utils_js_1.selectNamesNothingMessage)(table));
179
+ }
180
+ if (omit && !Array.isArray(omit) && Object.values(omit).some(Boolean)) {
181
+ throw new errors_js_1.ValidationError((0, utils_js_1.selectOmitExclusiveMessage)(table));
182
+ }
183
+ }
156
184
  if (select) {
157
185
  // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
158
186
  // 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;
@@ -37,6 +37,8 @@ exports.closestName = closestName;
37
37
  exports.suggestKey = suggestKey;
38
38
  exports.unknownFieldMessage = unknownFieldMessage;
39
39
  exports.relationInProjectionMessage = relationInProjectionMessage;
40
+ exports.selectNamesNothingMessage = selectNamesNothingMessage;
41
+ exports.selectOmitExclusiveMessage = selectOmitExclusiveMessage;
40
42
  const pg_1 = __importDefault(require("pg"));
41
43
  const schema_js_1 = require("../schema.js");
42
44
  const warn_registry_js_1 = require("./warn-registry.js");
@@ -911,3 +913,22 @@ function relationInProjectionMessage(table, field, clause) {
911
913
  : `${head} A relation is only present when you ask for it in \`with\`, so leave it out of \`with\` to leave it` +
912
914
  ' out of the result.';
913
915
  }
916
+ /**
917
+ * The two projection SHAPE refusals (0.65), shared by the SQL engines' single
918
+ * resolver, the batched loader's raw-arg check, and PowDB, so every path that
919
+ * refuses these shapes does so with one message.
920
+ *
921
+ * Both checks look at TRUTHY keys, and the raw-arg check in the batched
922
+ * loader exists because the loader force-adds correlation keys to a `select`
923
+ * before the resolver sees it: evaluated after that adjustment, an all-falsy
924
+ * user `select` looks populated and the verdict flips between strategies,
925
+ * which is exactly the class 0.64/0.65 exist to kill.
926
+ */
927
+ function selectNamesNothingMessage(table) {
928
+ return (`[turbine] "select" names no fields (on table "${table}"): every value is false or it is empty. ` +
929
+ `Pass at least one field as true, or drop "select" to get the default projection.`);
930
+ }
931
+ function selectOmitExclusiveMessage(table) {
932
+ return (`[turbine] "select" and "omit" are mutually exclusive (on table "${table}"). ` +
933
+ `A select already lists exactly the fields you want.`);
934
+ }
@@ -461,6 +461,22 @@ exports.sqliteDialect = {
461
461
  castAggregate(expr, target) {
462
462
  return `CAST(${expr} AS ${target === 'int' ? 'INTEGER' : 'REAL'})`;
463
463
  },
464
+ // SQLite's grammar only allows OFFSET after LIMIT, so `offset` without
465
+ // `limit` (valid on Postgres, which the default path is written for) is a
466
+ // syntax error here. A negative LIMIT is SQLite's documented "no upper
467
+ // bound", so that shape becomes `LIMIT -1 OFFSET n`. The other shapes emit
468
+ // byte-identically to the default path.
469
+ buildLimitOffset(input) {
470
+ const { limitPlaceholder, offsetPlaceholder } = input;
471
+ if (limitPlaceholder === undefined && offsetPlaceholder === undefined)
472
+ return '';
473
+ if (limitPlaceholder === undefined)
474
+ return ` LIMIT -1 OFFSET ${offsetPlaceholder}`;
475
+ let s = ` LIMIT ${limitPlaceholder}`;
476
+ if (offsetPlaceholder !== undefined)
477
+ s += ` OFFSET ${offsetPlaceholder}`;
478
+ return s;
479
+ },
464
480
  // No `= ANY(array)` in SQLite. `json_each` expands a single JSON-array param
465
481
  // into a row set, keeping ONE placeholder (so the SQL cache stays valid
466
482
  // regardless of list length) and handling the empty-list case correctly.