turbine-orm 0.62.1 → 0.63.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;
package/dist/cjs/powql.js CHANGED
@@ -909,6 +909,7 @@ class PowqlInterface {
909
909
  * and returns it regardless. Untagged tables project exactly as before.
910
910
  */
911
911
  projectedColumns(select, omit, includePii) {
912
+ const pk = new Set(this.meta.primaryKey);
912
913
  let cols = this.meta.columns.map((c) => c.name);
913
914
  const hasSelect = select && Object.keys(select).length;
914
915
  if (hasSelect) {
@@ -916,23 +917,33 @@ class PowqlInterface {
916
917
  .filter(([, v]) => v)
917
918
  .map(([k]) => this.column(k).name));
918
919
  // 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);
920
+ for (const key of pk)
921
+ picked.add(key);
921
922
  cols = cols.filter((c) => picked.has(c));
922
923
  }
923
924
  else if (!includePii) {
924
925
  // 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.
926
+ // caller names them in `select`), EXCEPT a PK column. This used to drop a
927
+ // tagged PK, on the reasoning that keys should not be tagged in the first
928
+ // place. That is good advice and a bad guarantee: the row it returns
929
+ // cannot address itself, so writing it back builds a partial predicate
930
+ // and mutates every row sharing the rest of the key. Same rule and same
931
+ // reason as `piiColumns` on the SQL engines.
927
932
  const pii = this.piiColumnNames();
928
933
  if (pii.size)
929
- cols = cols.filter((c) => !pii.has(c));
934
+ cols = cols.filter((c) => !pii.has(c) || pk.has(c));
930
935
  }
931
936
  if (omit && Object.keys(omit).length) {
932
937
  const dropped = new Set(Object.entries(omit)
933
938
  .filter(([, v]) => v)
934
939
  .map(([k]) => this.column(k).name));
935
- cols = cols.filter((c) => !dropped.has(c));
940
+ // The PK survives `omit` too. This filter ran unconditionally and after
941
+ // the `select` branch's force-add, so `omit: { id: true }` undid the very
942
+ // guarantee that force-add exists to provide: the m2m loader keys its
943
+ // target map on the PK (`targetByPk`), so every target collapsed onto the
944
+ // single bucket "undefined", no parent matched, and the relation came
945
+ // back `[]` for every row with no error.
946
+ cols = cols.filter((c) => !dropped.has(c) || pk.has(c));
936
947
  }
937
948
  return cols;
938
949
  }
@@ -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();
@@ -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.js CHANGED
@@ -873,6 +873,7 @@ export class PowqlInterface {
873
873
  * and returns it regardless. Untagged tables project exactly as before.
874
874
  */
875
875
  projectedColumns(select, omit, includePii) {
876
+ const pk = new Set(this.meta.primaryKey);
876
877
  let cols = this.meta.columns.map((c) => c.name);
877
878
  const hasSelect = select && Object.keys(select).length;
878
879
  if (hasSelect) {
@@ -880,23 +881,33 @@ export class PowqlInterface {
880
881
  .filter(([, v]) => v)
881
882
  .map(([k]) => this.column(k).name));
882
883
  // 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);
884
+ for (const key of pk)
885
+ picked.add(key);
885
886
  cols = cols.filter((c) => picked.has(c));
886
887
  }
887
888
  else if (!includePii) {
888
889
  // 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.
890
+ // caller names them in `select`), EXCEPT a PK column. This used to drop a
891
+ // tagged PK, on the reasoning that keys should not be tagged in the first
892
+ // place. That is good advice and a bad guarantee: the row it returns
893
+ // cannot address itself, so writing it back builds a partial predicate
894
+ // and mutates every row sharing the rest of the key. Same rule and same
895
+ // reason as `piiColumns` on the SQL engines.
891
896
  const pii = this.piiColumnNames();
892
897
  if (pii.size)
893
- cols = cols.filter((c) => !pii.has(c));
898
+ cols = cols.filter((c) => !pii.has(c) || pk.has(c));
894
899
  }
895
900
  if (omit && Object.keys(omit).length) {
896
901
  const dropped = new Set(Object.entries(omit)
897
902
  .filter(([, v]) => v)
898
903
  .map(([k]) => this.column(k).name));
899
- cols = cols.filter((c) => !dropped.has(c));
904
+ // The PK survives `omit` too. This filter ran unconditionally and after
905
+ // the `select` branch's force-add, so `omit: { id: true }` undid the very
906
+ // guarantee that force-add exists to provide: the m2m loader keys its
907
+ // target map on the PK (`targetByPk`), so every target collapsed onto the
908
+ // single bucket "undefined", no parent matched, and the relation came
909
+ // back `[]` for every row with no error.
910
+ cols = cols.filter((c) => !dropped.has(c) || pk.has(c));
900
911
  }
901
912
  return cols;
902
913
  }
@@ -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();
@@ -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.63.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).",