turbine-orm 0.62.1 → 0.64.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.
@@ -237,6 +237,18 @@ function pkWhere(tableMeta, row) {
237
237
  const where = {};
238
238
  for (const col of tableMeta.primaryKey) {
239
239
  const field = tableMeta.reverseColumnMap[col] ?? col;
240
+ // A PARTIAL primary key here is not a filter, it is a mass mutation.
241
+ // `undefined` values are dropped when the where compiles, and the
242
+ // empty-where guard only fires when NOTHING survives, so a composite PK
243
+ // missing one member compiles to a predicate on the remaining member and
244
+ // the statement rewrites every row that shares it. The row is supposed to
245
+ // be one this engine just read, so a missing member is an internal fault,
246
+ // and the only safe response is to refuse rather than to run.
247
+ if (row[field] === undefined) {
248
+ throw new errors_js_1.ValidationError(`[turbine] Cannot address a row of "${tableMeta.name}" by primary key: "${field}" is missing from the ` +
249
+ 'row this nested write is operating on, so the generated predicate would match more rows than intended. ' +
250
+ 'This is a bug in turbine, please report it.');
251
+ }
240
252
  where[field] = row[field];
241
253
  }
242
254
  return where;
@@ -307,6 +307,15 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
307
307
  * `includePii` is true; an explicit `select` naming a PII column IS the opt-in
308
308
  * and returns it regardless. Untagged tables project exactly as before.
309
309
  */
310
+ /**
311
+ * Resolve one projection field name, or throw. PowQL already refused an
312
+ * unresolvable name here (via {@link column}), unlike the SQL engines' join
313
+ * path, which filtered it out silently until 0.64. What this adds is the
314
+ * RELATION case: naming a relation inside `select` is a Prisma habit rather
315
+ * than a typo, and the generic "unknown column" text sends the reader looking
316
+ * for a misspelling that is not there. Same message as the SQL engines.
317
+ */
318
+ private projectionColumn;
310
319
  private projectedColumns;
311
320
  /**
312
321
  * The snake_case names of this table's PII-tagged columns. Empty for a table
package/dist/cjs/powql.js CHANGED
@@ -908,31 +908,56 @@ class PowqlInterface {
908
908
  * `includePii` is true; an explicit `select` naming a PII column IS the opt-in
909
909
  * and returns it regardless. Untagged tables project exactly as before.
910
910
  */
911
+ /**
912
+ * Resolve one projection field name, or throw. PowQL already refused an
913
+ * unresolvable name here (via {@link column}), unlike the SQL engines' join
914
+ * path, which filtered it out silently until 0.64. What this adds is the
915
+ * RELATION case: naming a relation inside `select` is a Prisma habit rather
916
+ * than a typo, and the generic "unknown column" text sends the reader looking
917
+ * for a misspelling that is not there. Same message as the SQL engines.
918
+ */
919
+ projectionColumn(field, clause) {
920
+ if ((0, utils_js_1.ownLookup)(this.meta.relations, field)) {
921
+ throw new errors_js_1.ValidationError((0, utils_js_1.relationInProjectionMessage)(this.table, field, clause));
922
+ }
923
+ return this.column(field).name;
924
+ }
911
925
  projectedColumns(select, omit, includePii) {
926
+ const pk = new Set(this.meta.primaryKey);
912
927
  let cols = this.meta.columns.map((c) => c.name);
913
928
  const hasSelect = select && Object.keys(select).length;
914
929
  if (hasSelect) {
915
930
  const picked = new Set(Object.entries(select)
916
931
  .filter(([, v]) => v)
917
- .map(([k]) => this.column(k).name));
932
+ .map(([k]) => this.projectionColumn(k, 'select')));
918
933
  // Always keep the PK so reselect / relation stitching has a key to work with.
919
- for (const pk of this.meta.primaryKey)
920
- picked.add(pk);
934
+ for (const key of pk)
935
+ picked.add(key);
921
936
  cols = cols.filter((c) => picked.has(c));
922
937
  }
923
938
  else if (!includePii) {
924
939
  // Default / omit-only projection: drop PII columns (kept above only when a
925
- // caller names them in `select`). PK is never PII in practice; if one is
926
- // tagged it is still dropped here, so tag sensitive data, not keys.
940
+ // caller names them in `select`), EXCEPT a PK column. This used to drop a
941
+ // tagged PK, on the reasoning that keys should not be tagged in the first
942
+ // place. That is good advice and a bad guarantee: the row it returns
943
+ // cannot address itself, so writing it back builds a partial predicate
944
+ // and mutates every row sharing the rest of the key. Same rule and same
945
+ // reason as `piiColumns` on the SQL engines.
927
946
  const pii = this.piiColumnNames();
928
947
  if (pii.size)
929
- cols = cols.filter((c) => !pii.has(c));
948
+ cols = cols.filter((c) => !pii.has(c) || pk.has(c));
930
949
  }
931
950
  if (omit && Object.keys(omit).length) {
932
951
  const dropped = new Set(Object.entries(omit)
933
952
  .filter(([, v]) => v)
934
- .map(([k]) => this.column(k).name));
935
- cols = cols.filter((c) => !dropped.has(c));
953
+ .map(([k]) => this.projectionColumn(k, 'omit')));
954
+ // The PK survives `omit` too. This filter ran unconditionally and after
955
+ // the `select` branch's force-add, so `omit: { id: true }` undid the very
956
+ // guarantee that force-add exists to provide: the m2m loader keys its
957
+ // target map on the PK (`targetByPk`), so every target collapsed onto the
958
+ // single bucket "undefined", no parent matched, and the relation came
959
+ // back `[]` for every row with no error.
960
+ cols = cols.filter((c) => !dropped.has(c) || pk.has(c));
936
961
  }
937
962
  return cols;
938
963
  }
@@ -304,6 +304,30 @@ async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0
304
304
  // A falsy `_count` opts out, exactly like a falsy relation spec.
305
305
  const countSpec = withClause._count;
306
306
  const hasCount = Boolean(countSpec);
307
+ // NESTED `_count` is refused here so the two strategies answer alike.
308
+ //
309
+ // The join builder emits `_count` only for the TOP-LEVEL `with`; one level
310
+ // down, `buildRelationSubquery` looks `_count` up as an ordinary relation
311
+ // name, misses, and throws E005. This loader handled it at every depth, so
312
+ // one query with one set of args either threw or returned populated counts
313
+ // depending purely on which plan ran, and under the `'auto'` default that
314
+ // choice is made by a cost heuristic reading index coverage and table size.
315
+ // The same code therefore worked on a small table and threw on a large one.
316
+ //
317
+ // That is the same non-determinism as the correlation-key bug this module was
318
+ // just fixed for, only louder, so it is closed the same way: by agreement.
319
+ // Refusing is the direction that is a no-op for anyone on the default, since
320
+ // there the shape already fails whenever the relation is NOT demoted. Nested
321
+ // `_count` is a real feature (Prisma has it) and teaching the join path is
322
+ // the right end state, but that means the four json_build_object emission
323
+ // sites, the positional encoding, SQL Server's FOR JSON override and PowDB's
324
+ // nested projections all learning it together, which is a feature release,
325
+ // not a line in a correctness fix. Until then both strategies say no.
326
+ if (hasCount && depth > 0) {
327
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "_count" on table "${ctx.parentMeta.name}". ` +
328
+ `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}. ` +
329
+ '(`_count` is supported on the top-level `with` only, on every relationLoadStrategy.)');
330
+ }
307
331
  // Fix key order BEFORE anything is awaited: the loads below all write their
308
332
  // key on completion, and completion order is a race between concurrent
309
333
  // statements.
@@ -395,6 +419,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
395
419
  const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
396
420
  const parentKeyField = ctx.parentMeta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
397
421
  const childKeyField = targetMeta.reverseColumnMap[childKeyCol] ?? childKeyCol;
422
+ assertCorrelationKeyProjected(parents, parentKeyField, relName, ctx.parentMeta.name);
398
423
  const keys = uniqueKeys(parents, parentKeyField);
399
424
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
400
425
  if (keys.length === 0) {
@@ -404,7 +429,16 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
404
429
  }
405
430
  // The follow-up must project the child correlation key even if the caller's
406
431
  // select/omit excluded it; strip it back off afterwards so the shape matches join.
407
- const proj = includeKeysForBatching(options.select, options.omit, [childKeyField], defaultProjectionFields(targetMeta, ctx.includePii));
432
+ //
433
+ // AND the keys the child's OWN nested relations will correlate on. Passing
434
+ // only `childKeyField` here was the whole of the silent-null bug: this level
435
+ // stitched fine, then the recursion below asked the children for a key that
436
+ // this projection had just dropped. The root call sites in builder.ts have
437
+ // always resolved the full set through `neededParentKeyFields`; these nested
438
+ // ones did not, so the defect needed a `select` (or an `omit` of the FK) on a
439
+ // to-many with a to-one inside it, which is why `include` and `join` were both
440
+ // clean and seventeen rounds of parity capture missed it.
441
+ const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
408
442
  const child = ctx.makeChild(rel.to);
409
443
  const chunks = [];
410
444
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
@@ -430,6 +464,12 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
430
464
  if (options.with && allChildren.length > 0) {
431
465
  await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
432
466
  }
467
+ // Symmetric to the parent-side assertion above. If the CHILD key were ever
468
+ // missing, `groupBy` would bucket every child under the string "undefined",
469
+ // no parent would match, and the relation would come back empty just as
470
+ // silently. `includeKeysForBatching` makes that unreachable, which is exactly
471
+ // what was true of the parent side until this week.
472
+ assertCorrelationKeyProjected(allChildren, childKeyField, relName, targetMeta.name);
433
473
  const byKey = groupBy(allChildren, childKeyField);
434
474
  const limit = options.limit;
435
475
  for (const parent of parents) {
@@ -468,6 +508,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
468
508
  const targetPkCol = targetMeta.primaryKey[0];
469
509
  const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
470
510
  const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
511
+ assertCorrelationKeyProjected(parents, parentRefField, relName, ctx.parentMeta.name);
471
512
  const parentKeys = uniqueKeys(parents, parentRefField);
472
513
  if (parentKeys.length === 0) {
473
514
  for (const parent of parents)
@@ -506,7 +547,10 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
506
547
  }
507
548
  }
508
549
  // (2) Target rows by PK, honouring the relation's own where/select/omit/orderBy.
509
- const proj = includeKeysForBatching(options.select, options.omit, [targetPkField], defaultProjectionFields(targetMeta, ctx.includePii));
550
+ // Plus the keys the target's own nested relations need, same rule and same
551
+ // reason as the to-many loader above: this level's PK is not the only key the
552
+ // recursion below will ask these rows for.
553
+ const proj = includeKeysForBatching(options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
510
554
  const child = ctx.makeChild(rel.to);
511
555
  const targetVals = [...targetValSet];
512
556
  const tChunks = [];
@@ -688,6 +732,48 @@ function mergeChildWhere(where, keyField, chunk) {
688
732
  return { AND: [where, correlation] };
689
733
  return { ...where, ...correlation };
690
734
  }
735
+ /**
736
+ * Refuse to stitch a relation whose correlation key was never projected.
737
+ *
738
+ * THE FAILURE THIS EXISTS TO REMOVE. `uniqueKeys` skips a row whose key is
739
+ * `null` OR `undefined`, and a column the projection left out is `undefined`
740
+ * for exactly the same reason it is for a genuinely null FK. So a loader that
741
+ * has lost its key cannot tell itself apart from a page of parents that
742
+ * legitimately point at nothing: both produce zero keys, and the empty-key
743
+ * branch hands back `null` / `[]` for every parent. That is a wrong answer with
744
+ * an HTTP 200 on it: a reporting endpoint summed a money column across the
745
+ * relation, every row of it read as absent, and the total came back 0 for a
746
+ * large fraction of a page. Nothing in the payload, the logs or the response
747
+ * status distinguished it from the right answer.
748
+ *
749
+ * The two states ARE distinguishable, just not by the value: a column that was
750
+ * not selected is ABSENT from the parsed entity (`'fk' in row === false`),
751
+ * while a selected column holding SQL NULL is PRESENT with the value `null`.
752
+ * Verified against a live database in both directions. So the discriminator is
753
+ * property presence (`Object.hasOwn`, not `in`: the rest of this file uses it,
754
+ * and a column named `constructor` or `toString` passes an `in` check on any
755
+ * plain object), and it is checked over ALL parents rather than the first:
756
+ * every row on one level shares one projection, so a field missing from every
757
+ * row is a projection fault, while a field missing from some rows is not a
758
+ * state this loader can produce at all.
759
+ *
760
+ * After {@link neededParentKeyFields} is applied at every nesting level this is
761
+ * an unreachable invariant, which is the point: if it ever fires it is a bug in
762
+ * Turbine, not in the caller's query, and the caller gets told so along with the
763
+ * one-line workaround. E017 with the same remedy as the composite-key refusal a
764
+ * few lines up, deliberately: to the caller both are "this strategy cannot serve
765
+ * this shape, use 'join'", and inventing a code for an unreachable state would
766
+ * be a new public error nobody can trigger.
767
+ */
768
+ function assertCorrelationKeyProjected(parents, field, relName, parentTable) {
769
+ if (parents.length === 0)
770
+ return;
771
+ if (parents.some((parent) => Object.hasOwn(parent, field)))
772
+ return;
773
+ throw new errors_js_1.UnsupportedFeatureError(`batched loading of relation "${relName}" on "${parentTable}"`, 'relationLoadStrategy: "batched"', `the correlation key "${field}" is missing from every parent row, so the relation cannot be stitched and ` +
774
+ 'would silently come back empty. This is a bug in turbine, please report it. ' +
775
+ `Workaround: pass \`relationLoadStrategy: 'join'\` on this query.`);
776
+ }
691
777
  /** Distinct, non-null values of `field` across `rows`. */
692
778
  function uniqueKeys(rows, field) {
693
779
  const seen = new Set();
@@ -34,8 +34,41 @@ export interface RelationShape {
34
34
  cardinality: 'many' | 'one';
35
35
  }
36
36
  /**
37
- * Resolve select/omit options into a list of snake_case column names.
38
- * Returns null if neither is provided (meaning all columns).
37
+ * Resolve `select` / `omit` into a list of snake_case column names, for the
38
+ * query's own table and for a relation target alike. `null` means "no
39
+ * projection", i.e. all columns, which keeps the `*` fast path.
40
+ *
41
+ * ## Why this is one function
42
+ *
43
+ * It used to be two, `resolveColumns` for the top level and
44
+ * `resolveTargetColumns` for a relation target, doing the same job against
45
+ * different metadata. They drifted, and the drift was invisible from either
46
+ * side: the top level resolved every name through a throwing lookup, while the
47
+ * relation side filtered unresolvable names out and emitted SQL for whatever
48
+ * survived. So the SAME key in the SAME query threw at the top and was silently
49
+ * ignored one level down, where `select: { titel: true }` returned `{}` rows
50
+ * and `omit: { titel: true }` returned the column it was asked to hide.
51
+ *
52
+ * It was worse than an inconsistency between depths. The batched loader runs
53
+ * each relation as a real query against the target table, so it went through
54
+ * the THROWING path, while the join plan went through the silent one. The two
55
+ * strategies therefore disagreed about whether the query was even valid, and
56
+ * under `relationLoadStrategy: 'auto'` which one runs is decided by a cost
57
+ * heuristic reading index coverage and table size. The same code threw on one
58
+ * table and quietly returned the wrong shape on another.
59
+ *
60
+ * Merging them is the fix that outlives this bug. Two functions that must agree
61
+ * are kept in step by whoever remembers; one function cannot disagree with
62
+ * itself. This mirrors `walkWhere` in where-compile.ts, which exists for the
63
+ * same reason after the top-level and relation-scoped WHERE walkers drifted
64
+ * twice, and it is why a new projection site is safe by default: PII exclusion,
65
+ * the `*` fast path and name resolution all live here, so reimplementing the
66
+ * name handling would mean reimplementing those too.
67
+ */
68
+ export declare function resolveProjection(qi: BuilderCtx, table: string, meta: TableMetadata, select?: Record<string, boolean>, omit?: Record<string, boolean>, includePii?: boolean): string[] | null;
69
+ /**
70
+ * The query's own table. Thin wrapper over {@link resolveProjection} kept for
71
+ * the existing call sites in builder.ts.
39
72
  */
40
73
  export declare function resolveColumns(qi: BuilderCtx, select?: Record<string, boolean>, omit?: Record<string, boolean>, includePii?: boolean): string[] | null;
41
74
  /**
@@ -254,8 +287,14 @@ export declare function parseNestedRow(qi: BuilderCtx, row: Record<string, unkno
254
287
  * Resolve the emitted column list for a relation, honoring `select` / `omit`.
255
288
  * Shared by {@link buildRelationSubquery} (json order) and
256
289
  * {@link buildRelationShape} (decode key order) so they can never diverge.
290
+ *
291
+ * A relation always projects SOMETHING, so the `null` that
292
+ * {@link resolveProjection} uses for the top level's `SELECT *` fast path
293
+ * becomes the target's full column list here. That is the only difference
294
+ * between the two, and it is why this is a four-line wrapper rather than a
295
+ * second implementation.
257
296
  */
258
- export declare function resolveTargetColumns(qi: BuilderCtx, spec: true | WithOptions, targetMeta: TableMetadata, includePii?: boolean): string[];
297
+ export declare function resolveTargetColumns(qi: BuilderCtx, spec: true | WithOptions, targetMeta: TableMetadata, includePii?: boolean, targetTable?: string): string[];
259
298
  /**
260
299
  * Render a single relation row's JSON: a keyed object (`'object'`) or a
261
300
  * positional array (`'positional'`). The array drops the keys but keeps the
@@ -47,6 +47,7 @@ var __importStar = (this && this.__importStar) || (function () {
47
47
  };
48
48
  })();
49
49
  Object.defineProperty(exports, "__esModule", { value: true });
50
+ exports.resolveProjection = resolveProjection;
50
51
  exports.resolveColumns = resolveColumns;
51
52
  exports.withFingerprint = withFingerprint;
52
53
  exports.collectWithParams = collectWithParams;
@@ -100,10 +101,58 @@ const warn_registry_js_1 = require("./warn-registry.js");
100
101
  const whereMod = __importStar(require("./where.js"));
101
102
  const writesMod = __importStar(require("./writes.js"));
102
103
  /**
103
- * Resolve select/omit options into a list of snake_case column names.
104
- * Returns null if neither is provided (meaning all columns).
104
+ * Turn ONE caller-supplied projection field name into a column, or throw.
105
+ *
106
+ * The whole point of this function is that it has no third outcome. The
107
+ * relation-side projection used to filter unresolvable names out
108
+ * (`.filter((col) => allColumns.includes(col))`) instead of rejecting them, and
109
+ * a filter that discards is exactly how a name typed by a human becomes SQL
110
+ * that no longer reflects what was asked for.
105
111
  */
106
- function resolveColumns(qi, select, omit, includePii) {
112
+ function projectionColumn(table, meta, field, clause) {
113
+ const column = (0, utils_js_1.resolveColumnName)(meta, field);
114
+ if (column)
115
+ return column;
116
+ // A relation named in a projection is a habit, not a typo, so it gets its own
117
+ // message pointing at `with`. Checked BEFORE the generic throw because the
118
+ // generic one degrades into "Did you mean <exactly what you typed>?".
119
+ if ((0, utils_js_1.ownLookup)(meta.relations, field))
120
+ throw new errors_js_1.ValidationError((0, utils_js_1.relationInProjectionMessage)(table, field, clause));
121
+ throw new errors_js_1.ValidationError((0, utils_js_1.unknownFieldMessage)(table, field, meta));
122
+ }
123
+ /**
124
+ * Resolve `select` / `omit` into a list of snake_case column names, for the
125
+ * query's own table and for a relation target alike. `null` means "no
126
+ * projection", i.e. all columns, which keeps the `*` fast path.
127
+ *
128
+ * ## Why this is one function
129
+ *
130
+ * It used to be two, `resolveColumns` for the top level and
131
+ * `resolveTargetColumns` for a relation target, doing the same job against
132
+ * different metadata. They drifted, and the drift was invisible from either
133
+ * side: the top level resolved every name through a throwing lookup, while the
134
+ * relation side filtered unresolvable names out and emitted SQL for whatever
135
+ * survived. So the SAME key in the SAME query threw at the top and was silently
136
+ * ignored one level down, where `select: { titel: true }` returned `{}` rows
137
+ * and `omit: { titel: true }` returned the column it was asked to hide.
138
+ *
139
+ * It was worse than an inconsistency between depths. The batched loader runs
140
+ * each relation as a real query against the target table, so it went through
141
+ * the THROWING path, while the join plan went through the silent one. The two
142
+ * strategies therefore disagreed about whether the query was even valid, and
143
+ * under `relationLoadStrategy: 'auto'` which one runs is decided by a cost
144
+ * heuristic reading index coverage and table size. The same code threw on one
145
+ * table and quietly returned the wrong shape on another.
146
+ *
147
+ * Merging them is the fix that outlives this bug. Two functions that must agree
148
+ * are kept in step by whoever remembers; one function cannot disagree with
149
+ * itself. This mirrors `walkWhere` in where-compile.ts, which exists for the
150
+ * same reason after the top-level and relation-scoped WHERE walkers drifted
151
+ * twice, and it is why a new projection site is safe by default: PII exclusion,
152
+ * the `*` fast path and name resolution all live here, so reimplementing the
153
+ * name handling would mean reimplementing those too.
154
+ */
155
+ function resolveProjection(qi, table, meta, select, omit, includePii) {
107
156
  if (select) {
108
157
  // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
109
158
  // style) instead of the object shape. Object.entries() would iterate the
@@ -117,12 +166,12 @@ function resolveColumns(qi, select, omit, includePii) {
117
166
  // PII column IS the opt-in: it comes back regardless of `includePii`.
118
167
  return Object.entries(select)
119
168
  .filter(([, v]) => v)
120
- .map(([k]) => qi.toColumn(k));
169
+ .map(([k]) => projectionColumn(table, meta, k, 'select'));
121
170
  }
122
171
  // Default / omit-only projection: PII-tagged columns are excluded unless the
123
172
  // caller opted in with `includePii: UNSAFE`. An empty set (untagged schema) keeps the
124
173
  // `null`/`*` fast path so the emitted SQL is byte-identical to before.
125
- const piiCols = includePii ? undefined : writesMod.piiColumns(qi, qi.tableMeta);
174
+ const piiCols = includePii ? undefined : writesMod.piiColumns(qi, meta);
126
175
  const hasPii = piiCols !== undefined && piiCols.size > 0;
127
176
  if (omit) {
128
177
  if (Array.isArray(omit)) {
@@ -131,14 +180,21 @@ function resolveColumns(qi, select, omit, includePii) {
131
180
  // Include all columns except those where value is true (and PII columns).
132
181
  const omitCols = new Set(Object.entries(omit)
133
182
  .filter(([, v]) => v)
134
- .map(([k]) => qi.toColumn(k)));
135
- return qi.tableMeta.allColumns.filter((col) => !omitCols.has(col) && !(hasPii && piiCols.has(col)));
183
+ .map(([k]) => projectionColumn(table, meta, k, 'omit')));
184
+ return meta.allColumns.filter((col) => !omitCols.has(col) && !(hasPii && piiCols.has(col)));
136
185
  }
137
186
  if (hasPii) {
138
- return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col));
187
+ return meta.allColumns.filter((col) => !piiCols.has(col));
139
188
  }
140
189
  return null;
141
190
  }
191
+ /**
192
+ * The query's own table. Thin wrapper over {@link resolveProjection} kept for
193
+ * the existing call sites in builder.ts.
194
+ */
195
+ function resolveColumns(qi, select, omit, includePii) {
196
+ return resolveProjection(qi, qi.table, qi.tableMeta, select, omit, includePii);
197
+ }
142
198
  /**
143
199
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
144
200
  * buildSelectWithRelations / buildRelationSubquery.
@@ -1210,30 +1266,17 @@ function parseNestedRow(qi, row, table, fromJson = false) {
1210
1266
  * Resolve the emitted column list for a relation, honoring `select` / `omit`.
1211
1267
  * Shared by {@link buildRelationSubquery} (json order) and
1212
1268
  * {@link buildRelationShape} (decode key order) so they can never diverge.
1269
+ *
1270
+ * A relation always projects SOMETHING, so the `null` that
1271
+ * {@link resolveProjection} uses for the top level's `SELECT *` fast path
1272
+ * becomes the target's full column list here. That is the only difference
1273
+ * between the two, and it is why this is a four-line wrapper rather than a
1274
+ * second implementation.
1213
1275
  */
1214
- function resolveTargetColumns(qi, spec, targetMeta, includePii) {
1215
- if (spec !== true && spec.select) {
1216
- // Explicit `select` names the columns: a PII column named here IS the
1217
- // opt-in and comes back regardless of the query's `includePii`.
1218
- const selectedFields = Object.entries(spec.select)
1219
- .filter(([, v]) => v)
1220
- .map(([k]) => (0, utils_js_1.ownLookup)(targetMeta.columnMap, k) ?? (0, schema_js_1.camelToSnake)(k));
1221
- return selectedFields.filter((col) => targetMeta.allColumns.includes(col));
1222
- }
1223
- // Default / omit-only relation projection: PII columns are excluded unless
1224
- // the query opted in via `includePii`.
1225
- const piiCols = includePii ? undefined : writesMod.piiColumns(qi, targetMeta);
1226
- const hasPii = piiCols !== undefined && piiCols.size > 0;
1227
- if (spec !== true && spec.omit) {
1228
- const omittedFields = new Set(Object.entries(spec.omit)
1229
- .filter(([, v]) => v)
1230
- .map(([k]) => (0, utils_js_1.ownLookup)(targetMeta.columnMap, k) ?? (0, schema_js_1.camelToSnake)(k)));
1231
- return targetMeta.allColumns.filter((col) => !omittedFields.has(col) && !(hasPii && piiCols.has(col)));
1232
- }
1233
- if (hasPii) {
1234
- return targetMeta.allColumns.filter((col) => !piiCols.has(col));
1235
- }
1236
- return targetMeta.allColumns;
1276
+ function resolveTargetColumns(qi, spec, targetMeta, includePii, targetTable = targetMeta.name) {
1277
+ const select = spec === true ? undefined : spec.select;
1278
+ const omit = spec === true ? undefined : spec.omit;
1279
+ return resolveProjection(qi, targetTable, targetMeta, select, omit, includePii) ?? targetMeta.allColumns;
1237
1280
  }
1238
1281
  /**
1239
1282
  * Render a single relation row's JSON: a keyed object (`'object'`) or a
@@ -1384,7 +1427,7 @@ function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
1384
1427
  const targetMeta = qi.schema.tables[relDef.to];
1385
1428
  if (!targetMeta)
1386
1429
  return { keys: [], nested: {}, cardinality: 'many' };
1387
- const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
1430
+ const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
1388
1431
  const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col));
1389
1432
  const nested = {};
1390
1433
  if (spec !== true && spec.with) {
@@ -1642,7 +1685,7 @@ function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includ
1642
1685
  }
1643
1686
  }
1644
1687
  const alias = `${FLATTEN_ALIAS_PREFIX}${counter.n++}`;
1645
- const cols = resolveTargetColumns(qi, spec, targetMeta, includePii);
1688
+ const cols = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
1646
1689
  const discAlias = `${alias}__${FLATTEN_DISCRIMINATOR}`;
1647
1690
  const node = {
1648
1691
  relName,
@@ -2212,7 +2255,7 @@ function buildRelationSubquery(qi, relDef, spec, params, parentRef, aliasCounter
2212
2255
  // `includePii` opt-in). Shared with the positional-shape builder so the
2213
2256
  // emitted json_build_array column order and the decode-side key order can
2214
2257
  // never drift apart.
2215
- const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
2258
+ const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
2216
2259
  // Engine override seam (additive): a dialect whose JSON-aggregation shape does
2217
2260
  // not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
2218
2261
  // the WHOLE subquery. Absent for PG/MySQL/SQLite → the native path below runs
@@ -451,3 +451,19 @@ export declare function unknownFieldMessage(table: string, field: string, meta:
451
451
  columnMap: Record<string, string>;
452
452
  relations?: Record<string, unknown>;
453
453
  }): string;
454
+ /**
455
+ * The error text for a RELATION named inside `select` / `omit`.
456
+ *
457
+ * Separate from {@link unknownFieldMessage} because the generic text degrades
458
+ * into nonsense here: `closestName` matches an exactly-spelled relation name at
459
+ * distance zero, so the message would read `Unknown field "comments". Did you
460
+ * mean "comments" (a relation)?`, which answers a question nobody asked and
461
+ * hides the actual fix.
462
+ *
463
+ * It is worth its own message for a second reason: this is not really a typo,
464
+ * it is a habit. Prisma nests a relation inside `select`, so writing
465
+ * `select: { comments: true }` is the natural first guess, and in Turbine a
466
+ * relation is loaded by `with`, which sits BESIDE `select` rather than inside
467
+ * it. Naming the fix costs one sentence and saves a search.
468
+ */
469
+ export declare function relationInProjectionMessage(table: string, field: string, clause: 'select' | 'omit'): string;
@@ -36,6 +36,7 @@ exports.coerceJsonWireValue = coerceJsonWireValue;
36
36
  exports.closestName = closestName;
37
37
  exports.suggestKey = suggestKey;
38
38
  exports.unknownFieldMessage = unknownFieldMessage;
39
+ exports.relationInProjectionMessage = relationInProjectionMessage;
39
40
  const pg_1 = __importDefault(require("pg"));
40
41
  const schema_js_1 = require("../schema.js");
41
42
  const warn_registry_js_1 = require("./warn-registry.js");
@@ -886,3 +887,27 @@ function unknownFieldMessage(table, field, meta) {
886
887
  ` Known columns: ${columns.join(', ') || '(none)'}.` +
887
888
  (relations.length ? ` Known relations (valid in \`where\` and \`with\`): ${relations.join(', ')}.` : ''));
888
889
  }
890
+ /**
891
+ * The error text for a RELATION named inside `select` / `omit`.
892
+ *
893
+ * Separate from {@link unknownFieldMessage} because the generic text degrades
894
+ * into nonsense here: `closestName` matches an exactly-spelled relation name at
895
+ * distance zero, so the message would read `Unknown field "comments". Did you
896
+ * mean "comments" (a relation)?`, which answers a question nobody asked and
897
+ * hides the actual fix.
898
+ *
899
+ * It is worth its own message for a second reason: this is not really a typo,
900
+ * it is a habit. Prisma nests a relation inside `select`, so writing
901
+ * `select: { comments: true }` is the natural first guess, and in Turbine a
902
+ * relation is loaded by `with`, which sits BESIDE `select` rather than inside
903
+ * it. Naming the fix costs one sentence and saves a search.
904
+ */
905
+ function relationInProjectionMessage(table, field, clause) {
906
+ const head = `[turbine] "${field}" is a relation on table "${table}", not a column, so it cannot be named in \`${clause}\`.`;
907
+ return clause === 'select'
908
+ ? `${head} Load it with \`with: { ${field}: true }\`, which is a sibling of \`select\`, not a member of it.` +
909
+ " To narrow the relation's own columns, put a `select` inside that relation's options:" +
910
+ ` \`with: { ${field}: { select: { … } } }\`.`
911
+ : `${head} A relation is only present when you ask for it in \`with\`, so leave it out of \`with\` to leave it` +
912
+ ' out of the result.';
913
+ }
@@ -706,8 +706,25 @@ function buildDeleteMany(qi, args) {
706
706
  */
707
707
  function piiColumns(_qi, meta) {
708
708
  const out = new Set();
709
+ const pk = new Set(meta.primaryKey);
709
710
  for (const col of meta.columns) {
710
- if (col.pii)
711
+ // A PII-tagged PRIMARY KEY column is NEVER stripped. The exemption lives
712
+ // here, in the helper every projection reads, rather than at the call
713
+ // sites: it was written once at the write site (`|| pk.has(col)`) and no
714
+ // read path had it, so a table whose PK member is tagged returned rows
715
+ // that could not address themselves. Round-tripping such a row into an
716
+ // update produced a PARTIAL predicate (the missing PK member is
717
+ // `undefined`, which the where compiler drops, and the empty-where guard
718
+ // does not fire because the OTHER member is present), so `update` silently
719
+ // rewrote every row sharing the remaining key instead of one. Measured on
720
+ // a composite `(org_id, email)` PK: three rows changed where one was
721
+ // asked for, no error.
722
+ //
723
+ // The policy this implements is the one already stated on
724
+ // {@link writeReturningColumns}: tag sensitive data, not keys; a PII PK is
725
+ // out of scope for stripping because the returned row must stay
726
+ // addressable. Only the enforcement was missing.
727
+ if (col.pii && !pk.has(col.name))
711
728
  out.add(col.name);
712
729
  }
713
730
  return out;
@@ -721,8 +738,14 @@ function piiColumns(_qi, meta) {
721
738
  */
722
739
  function piiFields(_qi, meta) {
723
740
  const out = [];
741
+ const pk = new Set(meta.primaryKey);
742
+ // Same PK exemption as {@link piiColumns}, and it MATTERS here rather than
743
+ // being belt-and-braces: this strip runs on an already-fetched row, so
744
+ // without it the SQL kept the PK addressable and this deleted it again,
745
+ // which is the exact contradiction between this function's own "no-op"
746
+ // docstring and writeReturningColumns' "must stay addressable".
724
747
  for (const col of meta.columns) {
725
- if (col.pii)
748
+ if (col.pii && !pk.has(col.name))
726
749
  out.push(col.field);
727
750
  }
728
751
  return out;
@@ -794,11 +817,12 @@ function namedColumns(meta, data) {
794
817
  return out;
795
818
  }
796
819
  function writeReturningColumns(qi) {
820
+ // The PK exemption used to be re-stated here as `|| pk.has(col)`. It now
821
+ // lives in `piiColumns` so every projection inherits it and none can drift.
797
822
  const piiCols = piiColumns(qi, qi.tableMeta);
798
823
  if (piiCols.size === 0)
799
824
  return '*';
800
- const pk = new Set(qi.tableMeta.primaryKey);
801
- return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col) || pk.has(col)).map((col) => qi.q(col));
825
+ return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col)).map((col) => qi.q(col));
802
826
  }
803
827
  /**
804
828
  * String form of {@link writeReturningColumns} for a `SELECT` list (the
@@ -229,6 +229,18 @@ function pkWhere(tableMeta, row) {
229
229
  const where = {};
230
230
  for (const col of tableMeta.primaryKey) {
231
231
  const field = tableMeta.reverseColumnMap[col] ?? col;
232
+ // A PARTIAL primary key here is not a filter, it is a mass mutation.
233
+ // `undefined` values are dropped when the where compiles, and the
234
+ // empty-where guard only fires when NOTHING survives, so a composite PK
235
+ // missing one member compiles to a predicate on the remaining member and
236
+ // the statement rewrites every row that shares it. The row is supposed to
237
+ // be one this engine just read, so a missing member is an internal fault,
238
+ // and the only safe response is to refuse rather than to run.
239
+ if (row[field] === undefined) {
240
+ throw new ValidationError(`[turbine] Cannot address a row of "${tableMeta.name}" by primary key: "${field}" is missing from the ` +
241
+ 'row this nested write is operating on, so the generated predicate would match more rows than intended. ' +
242
+ 'This is a bug in turbine, please report it.');
243
+ }
232
244
  where[field] = row[field];
233
245
  }
234
246
  return where;
package/dist/powql.d.ts CHANGED
@@ -307,6 +307,15 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
307
307
  * `includePii` is true; an explicit `select` naming a PII column IS the opt-in
308
308
  * and returns it regardless. Untagged tables project exactly as before.
309
309
  */
310
+ /**
311
+ * Resolve one projection field name, or throw. PowQL already refused an
312
+ * unresolvable name here (via {@link column}), unlike the SQL engines' join
313
+ * path, which filtered it out silently until 0.64. What this adds is the
314
+ * RELATION case: naming a relation inside `select` is a Prisma habit rather
315
+ * than a typo, and the generic "unknown column" text sends the reader looking
316
+ * for a misspelling that is not there. Same message as the SQL engines.
317
+ */
318
+ private projectionColumn;
310
319
  private projectedColumns;
311
320
  /**
312
321
  * The snake_case names of this table's PII-tagged columns. Empty for a table
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 } from './query/utils.js';
48
+ import { escapeLike, ownLookup, relationInProjectionMessage } from './query/utils.js';
49
49
  import { assertJsonFilterKeys, jsonStringEntries } from './query/where.js';
50
50
  import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
51
51
  /**
@@ -872,31 +872,56 @@ export class PowqlInterface {
872
872
  * `includePii` is true; an explicit `select` naming a PII column IS the opt-in
873
873
  * and returns it regardless. Untagged tables project exactly as before.
874
874
  */
875
+ /**
876
+ * Resolve one projection field name, or throw. PowQL already refused an
877
+ * unresolvable name here (via {@link column}), unlike the SQL engines' join
878
+ * path, which filtered it out silently until 0.64. What this adds is the
879
+ * RELATION case: naming a relation inside `select` is a Prisma habit rather
880
+ * than a typo, and the generic "unknown column" text sends the reader looking
881
+ * for a misspelling that is not there. Same message as the SQL engines.
882
+ */
883
+ projectionColumn(field, clause) {
884
+ if (ownLookup(this.meta.relations, field)) {
885
+ throw new ValidationError(relationInProjectionMessage(this.table, field, clause));
886
+ }
887
+ return this.column(field).name;
888
+ }
875
889
  projectedColumns(select, omit, includePii) {
890
+ const pk = new Set(this.meta.primaryKey);
876
891
  let cols = this.meta.columns.map((c) => c.name);
877
892
  const hasSelect = select && Object.keys(select).length;
878
893
  if (hasSelect) {
879
894
  const picked = new Set(Object.entries(select)
880
895
  .filter(([, v]) => v)
881
- .map(([k]) => this.column(k).name));
896
+ .map(([k]) => this.projectionColumn(k, 'select')));
882
897
  // Always keep the PK so reselect / relation stitching has a key to work with.
883
- for (const pk of this.meta.primaryKey)
884
- picked.add(pk);
898
+ for (const key of pk)
899
+ picked.add(key);
885
900
  cols = cols.filter((c) => picked.has(c));
886
901
  }
887
902
  else if (!includePii) {
888
903
  // Default / omit-only projection: drop PII columns (kept above only when a
889
- // caller names them in `select`). PK is never PII in practice; if one is
890
- // tagged it is still dropped here, so tag sensitive data, not keys.
904
+ // caller names them in `select`), EXCEPT a PK column. This used to drop a
905
+ // tagged PK, on the reasoning that keys should not be tagged in the first
906
+ // place. That is good advice and a bad guarantee: the row it returns
907
+ // cannot address itself, so writing it back builds a partial predicate
908
+ // and mutates every row sharing the rest of the key. Same rule and same
909
+ // reason as `piiColumns` on the SQL engines.
891
910
  const pii = this.piiColumnNames();
892
911
  if (pii.size)
893
- cols = cols.filter((c) => !pii.has(c));
912
+ cols = cols.filter((c) => !pii.has(c) || pk.has(c));
894
913
  }
895
914
  if (omit && Object.keys(omit).length) {
896
915
  const dropped = new Set(Object.entries(omit)
897
916
  .filter(([, v]) => v)
898
- .map(([k]) => this.column(k).name));
899
- cols = cols.filter((c) => !dropped.has(c));
917
+ .map(([k]) => this.projectionColumn(k, 'omit')));
918
+ // The PK survives `omit` too. This filter ran unconditionally and after
919
+ // the `select` branch's force-add, so `omit: { id: true }` undid the very
920
+ // guarantee that force-add exists to provide: the m2m loader keys its
921
+ // target map on the PK (`targetByPk`), so every target collapsed onto the
922
+ // single bucket "undefined", no parent matched, and the relation came
923
+ // back `[]` for every row with no error.
924
+ cols = cols.filter((c) => !dropped.has(c) || pk.has(c));
900
925
  }
901
926
  return cols;
902
927
  }
@@ -295,6 +295,30 @@ export async function loadRelationsBatched(ctx, parents, withClause, timeout, de
295
295
  // A falsy `_count` opts out, exactly like a falsy relation spec.
296
296
  const countSpec = withClause._count;
297
297
  const hasCount = Boolean(countSpec);
298
+ // NESTED `_count` is refused here so the two strategies answer alike.
299
+ //
300
+ // The join builder emits `_count` only for the TOP-LEVEL `with`; one level
301
+ // down, `buildRelationSubquery` looks `_count` up as an ordinary relation
302
+ // name, misses, and throws E005. This loader handled it at every depth, so
303
+ // one query with one set of args either threw or returned populated counts
304
+ // depending purely on which plan ran, and under the `'auto'` default that
305
+ // choice is made by a cost heuristic reading index coverage and table size.
306
+ // The same code therefore worked on a small table and threw on a large one.
307
+ //
308
+ // That is the same non-determinism as the correlation-key bug this module was
309
+ // just fixed for, only louder, so it is closed the same way: by agreement.
310
+ // Refusing is the direction that is a no-op for anyone on the default, since
311
+ // there the shape already fails whenever the relation is NOT demoted. Nested
312
+ // `_count` is a real feature (Prisma has it) and teaching the join path is
313
+ // the right end state, but that means the four json_build_object emission
314
+ // sites, the positional encoding, SQL Server's FOR JSON override and PowDB's
315
+ // nested projections all learning it together, which is a feature release,
316
+ // not a line in a correctness fix. Until then both strategies say no.
317
+ if (hasCount && depth > 0) {
318
+ throw new RelationError(`[turbine] Unknown relation "_count" on table "${ctx.parentMeta.name}". ` +
319
+ `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}. ` +
320
+ '(`_count` is supported on the top-level `with` only, on every relationLoadStrategy.)');
321
+ }
298
322
  // Fix key order BEFORE anything is awaited: the loads below all write their
299
323
  // key on completion, and completion order is a race between concurrent
300
324
  // statements.
@@ -386,6 +410,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
386
410
  const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
387
411
  const parentKeyField = ctx.parentMeta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
388
412
  const childKeyField = targetMeta.reverseColumnMap[childKeyCol] ?? childKeyCol;
413
+ assertCorrelationKeyProjected(parents, parentKeyField, relName, ctx.parentMeta.name);
389
414
  const keys = uniqueKeys(parents, parentKeyField);
390
415
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
391
416
  if (keys.length === 0) {
@@ -395,7 +420,16 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
395
420
  }
396
421
  // The follow-up must project the child correlation key even if the caller's
397
422
  // select/omit excluded it; strip it back off afterwards so the shape matches join.
398
- const proj = includeKeysForBatching(options.select, options.omit, [childKeyField], defaultProjectionFields(targetMeta, ctx.includePii));
423
+ //
424
+ // AND the keys the child's OWN nested relations will correlate on. Passing
425
+ // only `childKeyField` here was the whole of the silent-null bug: this level
426
+ // stitched fine, then the recursion below asked the children for a key that
427
+ // this projection had just dropped. The root call sites in builder.ts have
428
+ // always resolved the full set through `neededParentKeyFields`; these nested
429
+ // ones did not, so the defect needed a `select` (or an `omit` of the FK) on a
430
+ // to-many with a to-one inside it, which is why `include` and `join` were both
431
+ // clean and seventeen rounds of parity capture missed it.
432
+ const proj = includeKeysForBatching(options.select, options.omit, [childKeyField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
399
433
  const child = ctx.makeChild(rel.to);
400
434
  const chunks = [];
401
435
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
@@ -421,6 +455,12 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
421
455
  if (options.with && allChildren.length > 0) {
422
456
  await loadRelationsBatched({ ...ctx, parentMeta: targetMeta }, allChildren, options.with, timeout, depth + 1, [...path, relName]);
423
457
  }
458
+ // Symmetric to the parent-side assertion above. If the CHILD key were ever
459
+ // missing, `groupBy` would bucket every child under the string "undefined",
460
+ // no parent would match, and the relation would come back empty just as
461
+ // silently. `includeKeysForBatching` makes that unreachable, which is exactly
462
+ // what was true of the parent side until this week.
463
+ assertCorrelationKeyProjected(allChildren, childKeyField, relName, targetMeta.name);
424
464
  const byKey = groupBy(allChildren, childKeyField);
425
465
  const limit = options.limit;
426
466
  for (const parent of parents) {
@@ -459,6 +499,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
459
499
  const targetPkCol = targetMeta.primaryKey[0];
460
500
  const parentRefField = ctx.parentMeta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
461
501
  const targetPkField = targetMeta.reverseColumnMap[targetPkCol] ?? targetPkCol;
502
+ assertCorrelationKeyProjected(parents, parentRefField, relName, ctx.parentMeta.name);
462
503
  const parentKeys = uniqueKeys(parents, parentRefField);
463
504
  if (parentKeys.length === 0) {
464
505
  for (const parent of parents)
@@ -497,7 +538,10 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
497
538
  }
498
539
  }
499
540
  // (2) Target rows by PK, honouring the relation's own where/select/omit/orderBy.
500
- const proj = includeKeysForBatching(options.select, options.omit, [targetPkField], defaultProjectionFields(targetMeta, ctx.includePii));
541
+ // Plus the keys the target's own nested relations need, same rule and same
542
+ // reason as the to-many loader above: this level's PK is not the only key the
543
+ // recursion below will ask these rows for.
544
+ const proj = includeKeysForBatching(options.select, options.omit, [targetPkField, ...neededParentKeyFields(targetMeta, (options.with ?? {}))], defaultProjectionFields(targetMeta, ctx.includePii));
501
545
  const child = ctx.makeChild(rel.to);
502
546
  const targetVals = [...targetValSet];
503
547
  const tChunks = [];
@@ -679,6 +723,48 @@ function mergeChildWhere(where, keyField, chunk) {
679
723
  return { AND: [where, correlation] };
680
724
  return { ...where, ...correlation };
681
725
  }
726
+ /**
727
+ * Refuse to stitch a relation whose correlation key was never projected.
728
+ *
729
+ * THE FAILURE THIS EXISTS TO REMOVE. `uniqueKeys` skips a row whose key is
730
+ * `null` OR `undefined`, and a column the projection left out is `undefined`
731
+ * for exactly the same reason it is for a genuinely null FK. So a loader that
732
+ * has lost its key cannot tell itself apart from a page of parents that
733
+ * legitimately point at nothing: both produce zero keys, and the empty-key
734
+ * branch hands back `null` / `[]` for every parent. That is a wrong answer with
735
+ * an HTTP 200 on it: a reporting endpoint summed a money column across the
736
+ * relation, every row of it read as absent, and the total came back 0 for a
737
+ * large fraction of a page. Nothing in the payload, the logs or the response
738
+ * status distinguished it from the right answer.
739
+ *
740
+ * The two states ARE distinguishable, just not by the value: a column that was
741
+ * not selected is ABSENT from the parsed entity (`'fk' in row === false`),
742
+ * while a selected column holding SQL NULL is PRESENT with the value `null`.
743
+ * Verified against a live database in both directions. So the discriminator is
744
+ * property presence (`Object.hasOwn`, not `in`: the rest of this file uses it,
745
+ * and a column named `constructor` or `toString` passes an `in` check on any
746
+ * plain object), and it is checked over ALL parents rather than the first:
747
+ * every row on one level shares one projection, so a field missing from every
748
+ * row is a projection fault, while a field missing from some rows is not a
749
+ * state this loader can produce at all.
750
+ *
751
+ * After {@link neededParentKeyFields} is applied at every nesting level this is
752
+ * an unreachable invariant, which is the point: if it ever fires it is a bug in
753
+ * Turbine, not in the caller's query, and the caller gets told so along with the
754
+ * one-line workaround. E017 with the same remedy as the composite-key refusal a
755
+ * few lines up, deliberately: to the caller both are "this strategy cannot serve
756
+ * this shape, use 'join'", and inventing a code for an unreachable state would
757
+ * be a new public error nobody can trigger.
758
+ */
759
+ function assertCorrelationKeyProjected(parents, field, relName, parentTable) {
760
+ if (parents.length === 0)
761
+ return;
762
+ if (parents.some((parent) => Object.hasOwn(parent, field)))
763
+ return;
764
+ throw new UnsupportedFeatureError(`batched loading of relation "${relName}" on "${parentTable}"`, 'relationLoadStrategy: "batched"', `the correlation key "${field}" is missing from every parent row, so the relation cannot be stitched and ` +
765
+ 'would silently come back empty. This is a bug in turbine, please report it. ' +
766
+ `Workaround: pass \`relationLoadStrategy: 'join'\` on this query.`);
767
+ }
682
768
  /** Distinct, non-null values of `field` across `rows`. */
683
769
  function uniqueKeys(rows, field) {
684
770
  const seen = new Set();
@@ -34,8 +34,41 @@ export interface RelationShape {
34
34
  cardinality: 'many' | 'one';
35
35
  }
36
36
  /**
37
- * Resolve select/omit options into a list of snake_case column names.
38
- * Returns null if neither is provided (meaning all columns).
37
+ * Resolve `select` / `omit` into a list of snake_case column names, for the
38
+ * query's own table and for a relation target alike. `null` means "no
39
+ * projection", i.e. all columns, which keeps the `*` fast path.
40
+ *
41
+ * ## Why this is one function
42
+ *
43
+ * It used to be two, `resolveColumns` for the top level and
44
+ * `resolveTargetColumns` for a relation target, doing the same job against
45
+ * different metadata. They drifted, and the drift was invisible from either
46
+ * side: the top level resolved every name through a throwing lookup, while the
47
+ * relation side filtered unresolvable names out and emitted SQL for whatever
48
+ * survived. So the SAME key in the SAME query threw at the top and was silently
49
+ * ignored one level down, where `select: { titel: true }` returned `{}` rows
50
+ * and `omit: { titel: true }` returned the column it was asked to hide.
51
+ *
52
+ * It was worse than an inconsistency between depths. The batched loader runs
53
+ * each relation as a real query against the target table, so it went through
54
+ * the THROWING path, while the join plan went through the silent one. The two
55
+ * strategies therefore disagreed about whether the query was even valid, and
56
+ * under `relationLoadStrategy: 'auto'` which one runs is decided by a cost
57
+ * heuristic reading index coverage and table size. The same code threw on one
58
+ * table and quietly returned the wrong shape on another.
59
+ *
60
+ * Merging them is the fix that outlives this bug. Two functions that must agree
61
+ * are kept in step by whoever remembers; one function cannot disagree with
62
+ * itself. This mirrors `walkWhere` in where-compile.ts, which exists for the
63
+ * same reason after the top-level and relation-scoped WHERE walkers drifted
64
+ * twice, and it is why a new projection site is safe by default: PII exclusion,
65
+ * the `*` fast path and name resolution all live here, so reimplementing the
66
+ * name handling would mean reimplementing those too.
67
+ */
68
+ export declare function resolveProjection(qi: BuilderCtx, table: string, meta: TableMetadata, select?: Record<string, boolean>, omit?: Record<string, boolean>, includePii?: boolean): string[] | null;
69
+ /**
70
+ * The query's own table. Thin wrapper over {@link resolveProjection} kept for
71
+ * the existing call sites in builder.ts.
39
72
  */
40
73
  export declare function resolveColumns(qi: BuilderCtx, select?: Record<string, boolean>, omit?: Record<string, boolean>, includePii?: boolean): string[] | null;
41
74
  /**
@@ -254,8 +287,14 @@ export declare function parseNestedRow(qi: BuilderCtx, row: Record<string, unkno
254
287
  * Resolve the emitted column list for a relation, honoring `select` / `omit`.
255
288
  * Shared by {@link buildRelationSubquery} (json order) and
256
289
  * {@link buildRelationShape} (decode key order) so they can never diverge.
290
+ *
291
+ * A relation always projects SOMETHING, so the `null` that
292
+ * {@link resolveProjection} uses for the top level's `SELECT *` fast path
293
+ * becomes the target's full column list here. That is the only difference
294
+ * between the two, and it is why this is a four-line wrapper rather than a
295
+ * second implementation.
257
296
  */
258
- export declare function resolveTargetColumns(qi: BuilderCtx, spec: true | WithOptions, targetMeta: TableMetadata, includePii?: boolean): string[];
297
+ export declare function resolveTargetColumns(qi: BuilderCtx, spec: true | WithOptions, targetMeta: TableMetadata, includePii?: boolean, targetTable?: string): string[];
259
298
  /**
260
299
  * Render a single relation row's JSON: a keyed object (`'object'`) or a
261
300
  * positional array (`'positional'`). The array drops the keys but keeps the
@@ -18,15 +18,63 @@ 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 } from './utils.js';
21
+ import { ownLookup, relationInProjectionMessage, resolveColumnName, 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';
25
25
  /**
26
- * Resolve select/omit options into a list of snake_case column names.
27
- * Returns null if neither is provided (meaning all columns).
26
+ * Turn ONE caller-supplied projection field name into a column, or throw.
27
+ *
28
+ * The whole point of this function is that it has no third outcome. The
29
+ * relation-side projection used to filter unresolvable names out
30
+ * (`.filter((col) => allColumns.includes(col))`) instead of rejecting them, and
31
+ * a filter that discards is exactly how a name typed by a human becomes SQL
32
+ * that no longer reflects what was asked for.
28
33
  */
29
- export function resolveColumns(qi, select, omit, includePii) {
34
+ function projectionColumn(table, meta, field, clause) {
35
+ const column = resolveColumnName(meta, field);
36
+ if (column)
37
+ return column;
38
+ // A relation named in a projection is a habit, not a typo, so it gets its own
39
+ // message pointing at `with`. Checked BEFORE the generic throw because the
40
+ // generic one degrades into "Did you mean <exactly what you typed>?".
41
+ if (ownLookup(meta.relations, field))
42
+ throw new ValidationError(relationInProjectionMessage(table, field, clause));
43
+ throw new ValidationError(unknownFieldMessage(table, field, meta));
44
+ }
45
+ /**
46
+ * Resolve `select` / `omit` into a list of snake_case column names, for the
47
+ * query's own table and for a relation target alike. `null` means "no
48
+ * projection", i.e. all columns, which keeps the `*` fast path.
49
+ *
50
+ * ## Why this is one function
51
+ *
52
+ * It used to be two, `resolveColumns` for the top level and
53
+ * `resolveTargetColumns` for a relation target, doing the same job against
54
+ * different metadata. They drifted, and the drift was invisible from either
55
+ * side: the top level resolved every name through a throwing lookup, while the
56
+ * relation side filtered unresolvable names out and emitted SQL for whatever
57
+ * survived. So the SAME key in the SAME query threw at the top and was silently
58
+ * ignored one level down, where `select: { titel: true }` returned `{}` rows
59
+ * and `omit: { titel: true }` returned the column it was asked to hide.
60
+ *
61
+ * It was worse than an inconsistency between depths. The batched loader runs
62
+ * each relation as a real query against the target table, so it went through
63
+ * the THROWING path, while the join plan went through the silent one. The two
64
+ * strategies therefore disagreed about whether the query was even valid, and
65
+ * under `relationLoadStrategy: 'auto'` which one runs is decided by a cost
66
+ * heuristic reading index coverage and table size. The same code threw on one
67
+ * table and quietly returned the wrong shape on another.
68
+ *
69
+ * Merging them is the fix that outlives this bug. Two functions that must agree
70
+ * are kept in step by whoever remembers; one function cannot disagree with
71
+ * itself. This mirrors `walkWhere` in where-compile.ts, which exists for the
72
+ * same reason after the top-level and relation-scoped WHERE walkers drifted
73
+ * twice, and it is why a new projection site is safe by default: PII exclusion,
74
+ * the `*` fast path and name resolution all live here, so reimplementing the
75
+ * name handling would mean reimplementing those too.
76
+ */
77
+ export function resolveProjection(qi, table, meta, select, omit, includePii) {
30
78
  if (select) {
31
79
  // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
32
80
  // style) instead of the object shape. Object.entries() would iterate the
@@ -40,12 +88,12 @@ export function resolveColumns(qi, select, omit, includePii) {
40
88
  // PII column IS the opt-in: it comes back regardless of `includePii`.
41
89
  return Object.entries(select)
42
90
  .filter(([, v]) => v)
43
- .map(([k]) => qi.toColumn(k));
91
+ .map(([k]) => projectionColumn(table, meta, k, 'select'));
44
92
  }
45
93
  // Default / omit-only projection: PII-tagged columns are excluded unless the
46
94
  // caller opted in with `includePii: UNSAFE`. An empty set (untagged schema) keeps the
47
95
  // `null`/`*` fast path so the emitted SQL is byte-identical to before.
48
- const piiCols = includePii ? undefined : writesMod.piiColumns(qi, qi.tableMeta);
96
+ const piiCols = includePii ? undefined : writesMod.piiColumns(qi, meta);
49
97
  const hasPii = piiCols !== undefined && piiCols.size > 0;
50
98
  if (omit) {
51
99
  if (Array.isArray(omit)) {
@@ -54,14 +102,21 @@ export function resolveColumns(qi, select, omit, includePii) {
54
102
  // Include all columns except those where value is true (and PII columns).
55
103
  const omitCols = new Set(Object.entries(omit)
56
104
  .filter(([, v]) => v)
57
- .map(([k]) => qi.toColumn(k)));
58
- return qi.tableMeta.allColumns.filter((col) => !omitCols.has(col) && !(hasPii && piiCols.has(col)));
105
+ .map(([k]) => projectionColumn(table, meta, k, 'omit')));
106
+ return meta.allColumns.filter((col) => !omitCols.has(col) && !(hasPii && piiCols.has(col)));
59
107
  }
60
108
  if (hasPii) {
61
- return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col));
109
+ return meta.allColumns.filter((col) => !piiCols.has(col));
62
110
  }
63
111
  return null;
64
112
  }
113
+ /**
114
+ * The query's own table. Thin wrapper over {@link resolveProjection} kept for
115
+ * the existing call sites in builder.ts.
116
+ */
117
+ export function resolveColumns(qi, select, omit, includePii) {
118
+ return resolveProjection(qi, qi.table, qi.tableMeta, select, omit, includePii);
119
+ }
65
120
  /**
66
121
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
67
122
  * buildSelectWithRelations / buildRelationSubquery.
@@ -1133,30 +1188,17 @@ export function parseNestedRow(qi, row, table, fromJson = false) {
1133
1188
  * Resolve the emitted column list for a relation, honoring `select` / `omit`.
1134
1189
  * Shared by {@link buildRelationSubquery} (json order) and
1135
1190
  * {@link buildRelationShape} (decode key order) so they can never diverge.
1191
+ *
1192
+ * A relation always projects SOMETHING, so the `null` that
1193
+ * {@link resolveProjection} uses for the top level's `SELECT *` fast path
1194
+ * becomes the target's full column list here. That is the only difference
1195
+ * between the two, and it is why this is a four-line wrapper rather than a
1196
+ * second implementation.
1136
1197
  */
1137
- export function resolveTargetColumns(qi, spec, targetMeta, includePii) {
1138
- if (spec !== true && spec.select) {
1139
- // Explicit `select` names the columns: a PII column named here IS the
1140
- // opt-in and comes back regardless of the query's `includePii`.
1141
- const selectedFields = Object.entries(spec.select)
1142
- .filter(([, v]) => v)
1143
- .map(([k]) => ownLookup(targetMeta.columnMap, k) ?? camelToSnake(k));
1144
- return selectedFields.filter((col) => targetMeta.allColumns.includes(col));
1145
- }
1146
- // Default / omit-only relation projection: PII columns are excluded unless
1147
- // the query opted in via `includePii`.
1148
- const piiCols = includePii ? undefined : writesMod.piiColumns(qi, targetMeta);
1149
- const hasPii = piiCols !== undefined && piiCols.size > 0;
1150
- if (spec !== true && spec.omit) {
1151
- const omittedFields = new Set(Object.entries(spec.omit)
1152
- .filter(([, v]) => v)
1153
- .map(([k]) => ownLookup(targetMeta.columnMap, k) ?? camelToSnake(k)));
1154
- return targetMeta.allColumns.filter((col) => !omittedFields.has(col) && !(hasPii && piiCols.has(col)));
1155
- }
1156
- if (hasPii) {
1157
- return targetMeta.allColumns.filter((col) => !piiCols.has(col));
1158
- }
1159
- return targetMeta.allColumns;
1198
+ export function resolveTargetColumns(qi, spec, targetMeta, includePii, targetTable = targetMeta.name) {
1199
+ const select = spec === true ? undefined : spec.select;
1200
+ const omit = spec === true ? undefined : spec.omit;
1201
+ return resolveProjection(qi, targetTable, targetMeta, select, omit, includePii) ?? targetMeta.allColumns;
1160
1202
  }
1161
1203
  /**
1162
1204
  * Render a single relation row's JSON: a keyed object (`'object'`) or a
@@ -1307,7 +1349,7 @@ export function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
1307
1349
  const targetMeta = qi.schema.tables[relDef.to];
1308
1350
  if (!targetMeta)
1309
1351
  return { keys: [], nested: {}, cardinality: 'many' };
1310
- const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
1352
+ const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
1311
1353
  const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? snakeToCamel(col));
1312
1354
  const nested = {};
1313
1355
  if (spec !== true && spec.with) {
@@ -1565,7 +1607,7 @@ function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includ
1565
1607
  }
1566
1608
  }
1567
1609
  const alias = `${FLATTEN_ALIAS_PREFIX}${counter.n++}`;
1568
- const cols = resolveTargetColumns(qi, spec, targetMeta, includePii);
1610
+ const cols = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
1569
1611
  const discAlias = `${alias}__${FLATTEN_DISCRIMINATOR}`;
1570
1612
  const node = {
1571
1613
  relName,
@@ -2135,7 +2177,7 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2135
2177
  // `includePii` opt-in). Shared with the positional-shape builder so the
2136
2178
  // emitted json_build_array column order and the decode-side key order can
2137
2179
  // never drift apart.
2138
- const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii);
2180
+ const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
2139
2181
  // Engine override seam (additive): a dialect whose JSON-aggregation shape does
2140
2182
  // not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
2141
2183
  // the WHOLE subquery. Absent for PG/MySQL/SQLite → the native path below runs
@@ -451,3 +451,19 @@ export declare function unknownFieldMessage(table: string, field: string, meta:
451
451
  columnMap: Record<string, string>;
452
452
  relations?: Record<string, unknown>;
453
453
  }): string;
454
+ /**
455
+ * The error text for a RELATION named inside `select` / `omit`.
456
+ *
457
+ * Separate from {@link unknownFieldMessage} because the generic text degrades
458
+ * into nonsense here: `closestName` matches an exactly-spelled relation name at
459
+ * distance zero, so the message would read `Unknown field "comments". Did you
460
+ * mean "comments" (a relation)?`, which answers a question nobody asked and
461
+ * hides the actual fix.
462
+ *
463
+ * It is worth its own message for a second reason: this is not really a typo,
464
+ * it is a habit. Prisma nests a relation inside `select`, so writing
465
+ * `select: { comments: true }` is the natural first guess, and in Turbine a
466
+ * relation is loaded by `with`, which sits BESIDE `select` rather than inside
467
+ * it. Naming the fix costs one sentence and saves a search.
468
+ */
469
+ export declare function relationInProjectionMessage(table: string, field: string, clause: 'select' | 'omit'): string;
@@ -852,3 +852,27 @@ export function unknownFieldMessage(table, field, meta) {
852
852
  ` Known columns: ${columns.join(', ') || '(none)'}.` +
853
853
  (relations.length ? ` Known relations (valid in \`where\` and \`with\`): ${relations.join(', ')}.` : ''));
854
854
  }
855
+ /**
856
+ * The error text for a RELATION named inside `select` / `omit`.
857
+ *
858
+ * Separate from {@link unknownFieldMessage} because the generic text degrades
859
+ * into nonsense here: `closestName` matches an exactly-spelled relation name at
860
+ * distance zero, so the message would read `Unknown field "comments". Did you
861
+ * mean "comments" (a relation)?`, which answers a question nobody asked and
862
+ * hides the actual fix.
863
+ *
864
+ * It is worth its own message for a second reason: this is not really a typo,
865
+ * it is a habit. Prisma nests a relation inside `select`, so writing
866
+ * `select: { comments: true }` is the natural first guess, and in Turbine a
867
+ * relation is loaded by `with`, which sits BESIDE `select` rather than inside
868
+ * it. Naming the fix costs one sentence and saves a search.
869
+ */
870
+ export function relationInProjectionMessage(table, field, clause) {
871
+ const head = `[turbine] "${field}" is a relation on table "${table}", not a column, so it cannot be named in \`${clause}\`.`;
872
+ return clause === 'select'
873
+ ? `${head} Load it with \`with: { ${field}: true }\`, which is a sibling of \`select\`, not a member of it.` +
874
+ " To narrow the relation's own columns, put a `select` inside that relation's options:" +
875
+ ` \`with: { ${field}: { select: { … } } }\`.`
876
+ : `${head} A relation is only present when you ask for it in \`with\`, so leave it out of \`with\` to leave it` +
877
+ ' out of the result.';
878
+ }
@@ -649,8 +649,25 @@ export function buildDeleteMany(qi, args) {
649
649
  */
650
650
  export function piiColumns(_qi, meta) {
651
651
  const out = new Set();
652
+ const pk = new Set(meta.primaryKey);
652
653
  for (const col of meta.columns) {
653
- if (col.pii)
654
+ // A PII-tagged PRIMARY KEY column is NEVER stripped. The exemption lives
655
+ // here, in the helper every projection reads, rather than at the call
656
+ // sites: it was written once at the write site (`|| pk.has(col)`) and no
657
+ // read path had it, so a table whose PK member is tagged returned rows
658
+ // that could not address themselves. Round-tripping such a row into an
659
+ // update produced a PARTIAL predicate (the missing PK member is
660
+ // `undefined`, which the where compiler drops, and the empty-where guard
661
+ // does not fire because the OTHER member is present), so `update` silently
662
+ // rewrote every row sharing the remaining key instead of one. Measured on
663
+ // a composite `(org_id, email)` PK: three rows changed where one was
664
+ // asked for, no error.
665
+ //
666
+ // The policy this implements is the one already stated on
667
+ // {@link writeReturningColumns}: tag sensitive data, not keys; a PII PK is
668
+ // out of scope for stripping because the returned row must stay
669
+ // addressable. Only the enforcement was missing.
670
+ if (col.pii && !pk.has(col.name))
654
671
  out.add(col.name);
655
672
  }
656
673
  return out;
@@ -664,8 +681,14 @@ export function piiColumns(_qi, meta) {
664
681
  */
665
682
  export function piiFields(_qi, meta) {
666
683
  const out = [];
684
+ const pk = new Set(meta.primaryKey);
685
+ // Same PK exemption as {@link piiColumns}, and it MATTERS here rather than
686
+ // being belt-and-braces: this strip runs on an already-fetched row, so
687
+ // without it the SQL kept the PK addressable and this deleted it again,
688
+ // which is the exact contradiction between this function's own "no-op"
689
+ // docstring and writeReturningColumns' "must stay addressable".
667
690
  for (const col of meta.columns) {
668
- if (col.pii)
691
+ if (col.pii && !pk.has(col.name))
669
692
  out.push(col.field);
670
693
  }
671
694
  return out;
@@ -737,11 +760,12 @@ function namedColumns(meta, data) {
737
760
  return out;
738
761
  }
739
762
  export function writeReturningColumns(qi) {
763
+ // The PK exemption used to be re-stated here as `|| pk.has(col)`. It now
764
+ // lives in `piiColumns` so every projection inherits it and none can drift.
740
765
  const piiCols = piiColumns(qi, qi.tableMeta);
741
766
  if (piiCols.size === 0)
742
767
  return '*';
743
- const pk = new Set(qi.tableMeta.primaryKey);
744
- return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col) || pk.has(col)).map((col) => qi.q(col));
768
+ return qi.tableMeta.allColumns.filter((col) => !piiCols.has(col)).map((col) => qi.q(col));
745
769
  }
746
770
  /**
747
771
  * String form of {@link writeReturningColumns} for a `SELECT` list (the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.62.1",
3
+ "version": "0.64.0",
4
4
  "description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",