turbine-orm 0.61.0 → 0.62.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.
Files changed (82) hide show
  1. package/README.md +65 -21
  2. package/dist/cjs/cli/config.d.ts +40 -0
  3. package/dist/cjs/cli/config.js +74 -2
  4. package/dist/cjs/cli/index.d.ts +85 -1
  5. package/dist/cjs/cli/index.js +323 -24
  6. package/dist/cjs/cli/mcp.d.ts +8 -0
  7. package/dist/cjs/cli/mcp.js +448 -29
  8. package/dist/cjs/cli/pii-tags.d.ts +64 -9
  9. package/dist/cjs/cli/pii-tags.js +218 -39
  10. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  11. package/dist/cjs/cli/studio.d.ts +23 -0
  12. package/dist/cjs/cli/studio.js +126 -53
  13. package/dist/cjs/cli/ui.d.ts +15 -1
  14. package/dist/cjs/cli/ui.js +19 -5
  15. package/dist/cjs/client.js +186 -3
  16. package/dist/cjs/errors.d.ts +38 -1
  17. package/dist/cjs/errors.js +235 -24
  18. package/dist/cjs/index.d.ts +2 -2
  19. package/dist/cjs/index.js +7 -2
  20. package/dist/cjs/pipeline.js +15 -2
  21. package/dist/cjs/powql.d.ts +12 -0
  22. package/dist/cjs/powql.js +46 -21
  23. package/dist/cjs/prisma-compat.d.ts +15 -5
  24. package/dist/cjs/prisma-compat.js +273 -78
  25. package/dist/cjs/query/aggregates.d.ts +1 -1
  26. package/dist/cjs/query/aggregates.js +24 -10
  27. package/dist/cjs/query/batched-loader.d.ts +9 -4
  28. package/dist/cjs/query/batched-loader.js +4 -1
  29. package/dist/cjs/query/builder.d.ts +47 -0
  30. package/dist/cjs/query/builder.js +125 -21
  31. package/dist/cjs/query/index.d.ts +3 -1
  32. package/dist/cjs/query/index.js +7 -1
  33. package/dist/cjs/query/option-surface.d.ts +11 -0
  34. package/dist/cjs/query/option-surface.js +13 -0
  35. package/dist/cjs/query/relations.d.ts +8 -0
  36. package/dist/cjs/query/relations.js +21 -1
  37. package/dist/cjs/query/types.d.ts +152 -18
  38. package/dist/cjs/query/types.js +212 -1
  39. package/dist/cjs/query/where.d.ts +3 -3
  40. package/dist/cjs/query/where.js +8 -2
  41. package/dist/cjs/query/writes.js +10 -9
  42. package/dist/cli/config.d.ts +40 -0
  43. package/dist/cli/config.js +73 -2
  44. package/dist/cli/index.d.ts +85 -1
  45. package/dist/cli/index.js +321 -26
  46. package/dist/cli/mcp.d.ts +8 -0
  47. package/dist/cli/mcp.js +448 -29
  48. package/dist/cli/pii-tags.d.ts +64 -9
  49. package/dist/cli/pii-tags.js +217 -39
  50. package/dist/cli/studio-ui.generated.js +1 -1
  51. package/dist/cli/studio.d.ts +23 -0
  52. package/dist/cli/studio.js +125 -53
  53. package/dist/cli/ui.d.ts +15 -1
  54. package/dist/cli/ui.js +18 -4
  55. package/dist/client.js +187 -4
  56. package/dist/errors.d.ts +38 -1
  57. package/dist/errors.js +234 -23
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +5 -2
  60. package/dist/pipeline.js +15 -2
  61. package/dist/powql.d.ts +12 -0
  62. package/dist/powql.js +46 -21
  63. package/dist/prisma-compat.d.ts +15 -5
  64. package/dist/prisma-compat.js +274 -79
  65. package/dist/query/aggregates.d.ts +1 -1
  66. package/dist/query/aggregates.js +24 -10
  67. package/dist/query/batched-loader.d.ts +9 -4
  68. package/dist/query/batched-loader.js +4 -1
  69. package/dist/query/builder.d.ts +47 -0
  70. package/dist/query/builder.js +124 -21
  71. package/dist/query/index.d.ts +3 -1
  72. package/dist/query/index.js +2 -0
  73. package/dist/query/option-surface.d.ts +11 -0
  74. package/dist/query/option-surface.js +13 -0
  75. package/dist/query/relations.d.ts +8 -0
  76. package/dist/query/relations.js +21 -1
  77. package/dist/query/types.d.ts +152 -18
  78. package/dist/query/types.js +207 -2
  79. package/dist/query/where.d.ts +3 -3
  80. package/dist/query/where.js +8 -2
  81. package/dist/query/writes.js +10 -9
  82. package/package.json +13 -3
@@ -41,6 +41,11 @@
41
41
  * by this layer, and a key that is neither a Prisma arg nor a turbine option
42
42
  * gets a dev-mode warning instead of vanishing (see
43
43
  * {@link PRISMA_ARG_KEYS} and `warnUnknownQueryOptions`).
44
+ * The three PRIVILEGE options among them (`skipGlobalFilters`, `includePii`,
45
+ * `allowFullTableScan`) are forwarded VERBATIM, which is what makes them safe
46
+ * here: core accepts only the `UNSAFE` sentinel, so a Prisma-shaped args
47
+ * object carrying `includePii: true` reaches core and is refused there, and
48
+ * this layer never needs its own copy of that rule.
44
49
  *
45
50
  * ## What it deliberately does NOT do (documented divergences)
46
51
  *
@@ -76,11 +81,16 @@
76
81
  * - **`limit` on `updateMany` / `deleteMany`** (Prisma 6.7+) throws. Turbine has
77
82
  * no row-bounded mass mutation, and dropping a SAFETY BOUND with a warning
78
83
  * would turn "change at most 10 rows" into "change every matching row".
79
- * - **Write projections** (`select` / `include` / `omit` on
80
- * create/update/delete/upsert), **`select` on `count`**, and
81
- * **`orderBy` / `cursor` / `take` / `skip` on `aggregate`** are accepted and
82
- * IGNORED (they are legitimate Prisma, so they never warn); the full row / a
83
- * plain number comes back.
84
+ * - **Write projections**: `select` / `omit` on create/update/delete/upsert
85
+ * narrow the RETURNED OBJECT (see {@link resolveWriteProjection}); they do
86
+ * NOT narrow the statement's `RETURNING` list, so the column still crosses
87
+ * the wire, and `pii: true` remains the SQL-level control. A relation key in
88
+ * either (or any `include` on a write) THROWS, since a write returns its own
89
+ * row only. `omit` on READS is translated to core's own `omit`, see
90
+ * {@link translateOmit}.
91
+ * - **`select` on `count`** and **`orderBy` / `cursor` / `take` / `skip` on
92
+ * `aggregate`** are accepted and IGNORED (they are legitimate Prisma, so they
93
+ * never warn); a plain number / the aggregate comes back.
84
94
  *
85
95
  * ## Type dependencies (0.41.0)
86
96
  *
@@ -638,16 +648,53 @@ function mapTake(take) {
638
648
  return take;
639
649
  }
640
650
  /**
641
- * Translate Prisma `include` / `select` into Turbine `{ select, with }`.
642
- * `include` keeps all scalars and adds relations; `select` narrows scalars and
643
- * may also pull relations + `_count`. The two are mutually exclusive.
651
+ * Translate a Prisma `omit` (scalar field names → true) into Turbine's `omit`.
652
+ *
653
+ * This used to be DROPPED silently, which made it a data-exposure bug rather
654
+ * than an ergonomics one: `omit` is the idiom for a sensitive-but-untagged
655
+ * column (`passwordHash`, `resetToken`), so a caller who asked for it got the
656
+ * column back anyway, in a response they believed was already filtered. Turbine
657
+ * core has supported `omit` on reads all along; only this translation was
658
+ * missing.
659
+ *
660
+ * A relation name is refused rather than forwarded: core's `omit` narrows the
661
+ * SCALAR projection, so `omit: { posts: true }` would have compiled to nothing
662
+ * and dropped the caller's intent a second time. Prisma rejects it too.
663
+ */
664
+ function translateOmit(ctx, mm, omit) {
665
+ const out = {};
666
+ let any = false;
667
+ for (const [key, val] of Object.entries(omit)) {
668
+ if (val === false || val == null)
669
+ continue;
670
+ if (mm.relations[key]) {
671
+ throw new errors_js_1.ValidationError(`[turbine] prisma-compat: \`omit\` on model "${modelName(ctx, mm)}" takes scalar fields, but "${key}" ` +
672
+ 'is a relation. Leave the relation out of `include` instead.');
673
+ }
674
+ out[renameField(mm, key)] = true;
675
+ any = true;
676
+ }
677
+ return any ? out : undefined;
678
+ }
679
+ /**
680
+ * Translate Prisma `include` / `select` / `omit` into Turbine
681
+ * `{ select, omit, with }`. `include` keeps all scalars and adds relations;
682
+ * `select` narrows scalars and may also pull relations + `_count`. `include`
683
+ * and `select` are mutually exclusive, and so are `select` and `omit` (a
684
+ * narrowed projection minus fields is ambiguous; Prisma refuses the pair too).
644
685
  */
645
686
  function translateProjection(ctx, mm, args) {
646
687
  const include = args.include;
647
688
  const select = args.select;
689
+ const omitArg = args.omit;
648
690
  if (include && select) {
649
691
  throw new errors_js_1.ValidationError('[turbine] prisma-compat: `include` and `select` are mutually exclusive.');
650
692
  }
693
+ if (select && omitArg) {
694
+ throw new errors_js_1.ValidationError('[turbine] prisma-compat: `select` and `omit` are mutually exclusive. ' +
695
+ 'A `select` already lists exactly the fields you want.');
696
+ }
697
+ const omit = omitArg ? translateOmit(ctx, mm, omitArg) : undefined;
651
698
  const withClause = {};
652
699
  let hasWith = false;
653
700
  if (include) {
@@ -666,7 +713,7 @@ function translateProjection(ctx, mm, args) {
666
713
  withClause[rel.name] = translateWithOption(ctx, mm, rel.name, val);
667
714
  hasWith = true;
668
715
  }
669
- return { with: hasWith ? withClause : undefined };
716
+ return { omit, with: hasWith ? withClause : undefined };
670
717
  }
671
718
  if (select) {
672
719
  const scalar = {};
@@ -690,7 +737,130 @@ function translateProjection(ctx, mm, args) {
690
737
  }
691
738
  return { select: hasScalar ? scalar : undefined, with: hasWith ? withClause : undefined };
692
739
  }
693
- return {};
740
+ return { omit };
741
+ }
742
+ const NO_WRITE_PROJECTION = {};
743
+ /**
744
+ * Refuse a write-projection key that names no field on the model.
745
+ *
746
+ * Unlike a READ projection, this one never reaches core: `select` / `omit` on a
747
+ * write are applied to the already-returned object by
748
+ * {@link applyWriteProjection}, so nothing downstream can notice the name is
749
+ * wrong. A typo therefore failed silently in BOTH directions, and the `omit`
750
+ * direction is the one that matters: `omit: { ssn: true }` misspelled as
751
+ * `snn` returned the column, and the call site that asked for it hidden had no
752
+ * signal at all. (`pick` failed the other way, quietly dropping the field from
753
+ * the result.)
754
+ *
755
+ * The read path has never had this hole: core validates every projection key
756
+ * and throws E003 on an unknown one, which is the behaviour a caller has
757
+ * already observed on `findMany` before writing the same clause on `create`.
758
+ * So this is not a new rule, it is the read rule reaching the one surface that
759
+ * bypassed it. Errors match core's shape, including the suggestion.
760
+ */
761
+ function assertWriteProjectionField(ctx, mm, op, clause, key) {
762
+ if (Object.hasOwn(mm.fields, key))
763
+ return;
764
+ const known = Object.keys(mm.fields);
765
+ const suggestion = (0, utils_js_1.suggestKey)(key, new Set(known));
766
+ throw new errors_js_1.ValidationError(`[turbine] prisma-compat: unknown field "${key}" in \`${clause}\` on ${modelName(ctx, mm)}.${op}().` +
767
+ (suggestion ? ` Did you mean "${suggestion}"?` : '') +
768
+ ` Known fields: ${known.join(', ') || '(none)'}.`);
769
+ }
770
+ /**
771
+ * Resolve `select` / `omit` / `include` on create / update / delete / upsert.
772
+ *
773
+ * All three used to be accepted and IGNORED here. For `omit` that is the exact
774
+ * data-exposure bug that was fixed for reads: `omit` is the idiom for a
775
+ * sensitive-but-untagged column, and because it demonstrably WORKS on
776
+ * `findMany`, a caller has positive evidence for assuming it works on `create`
777
+ * too. `select` is the same class in the other spelling (the caller asked for
778
+ * three fields and got the whole row).
779
+ *
780
+ * SCOPE, stated exactly rather than implied: this narrows the OBJECT this
781
+ * adapter returns. Core's write args carry no projection, so the column is
782
+ * still in the statement's `RETURNING` list and still crosses the wire. The
783
+ * SQL-level control is `pii: true` on the column, which is enforced in core and
784
+ * is unaffected by any of this.
785
+ *
786
+ * A relation (or `_count`) key THROWS instead: a write returns its own row and
787
+ * nothing else, so serving it is impossible, and the alternative is handing
788
+ * back a row whose relation property is silently `undefined`, which is the
789
+ * failure mode this whole round exists to remove.
790
+ */
791
+ function resolveWriteProjection(ctx, mm, op, args) {
792
+ if (!args)
793
+ return NO_WRITE_PROJECTION;
794
+ const include = args.include;
795
+ const select = args.select;
796
+ const omitArg = args.omit;
797
+ if (include && select) {
798
+ throw new errors_js_1.ValidationError('[turbine] prisma-compat: `include` and `select` are mutually exclusive.');
799
+ }
800
+ if (select && omitArg) {
801
+ throw new errors_js_1.ValidationError('[turbine] prisma-compat: `select` and `omit` are mutually exclusive. ' +
802
+ 'A `select` already lists exactly the fields you want.');
803
+ }
804
+ if (include) {
805
+ for (const [key, val] of Object.entries(include)) {
806
+ if (val === false || val == null)
807
+ continue;
808
+ throw new errors_js_1.UnsupportedFeatureError(`include ("${key}") on ${op}`, 'prisma-compat', `a write on model "${modelName(ctx, mm)}" returns its own row only; read the relation back with a ` +
809
+ 'separate findUnique inside the same $transaction.');
810
+ }
811
+ }
812
+ if (select) {
813
+ const pick = new Set();
814
+ for (const [key, val] of Object.entries(select)) {
815
+ if (val === false || val == null)
816
+ continue;
817
+ if (key === '_count' || mm.relations[key]) {
818
+ throw new errors_js_1.UnsupportedFeatureError(`select of "${key}" on ${op}`, 'prisma-compat', `"${key}" is a relation on model "${modelName(ctx, mm)}"; a write returns its own row only, so read ` +
819
+ 'it back with a separate findUnique inside the same $transaction.');
820
+ }
821
+ assertWriteProjectionField(ctx, mm, op, 'select', key);
822
+ pick.add(key);
823
+ }
824
+ // An all-false `select` names nothing. Reads treat that as "no projection"
825
+ // (translateProjection returns select: undefined), so writes do too rather
826
+ // than inventing a second rule for input Prisma rejects anyway.
827
+ return pick.size ? { pick } : NO_WRITE_PROJECTION;
828
+ }
829
+ if (omitArg) {
830
+ const omit = new Set();
831
+ for (const [key, val] of Object.entries(omitArg)) {
832
+ if (val === false || val == null)
833
+ continue;
834
+ if (mm.relations[key]) {
835
+ throw new errors_js_1.ValidationError(`[turbine] prisma-compat: \`omit\` on model "${modelName(ctx, mm)}" takes scalar fields, but "${key}" ` +
836
+ 'is a relation.');
837
+ }
838
+ assertWriteProjectionField(ctx, mm, op, 'omit', key);
839
+ omit.add(key);
840
+ }
841
+ return omit.size ? { omit } : NO_WRITE_PROJECTION;
842
+ }
843
+ return NO_WRITE_PROJECTION;
844
+ }
845
+ /** Apply a {@link WriteProjection} to one already-reshaped write result row. */
846
+ function applyWriteProjection(proj, row) {
847
+ if (!isPlainObject(row))
848
+ return row;
849
+ if (proj.pick) {
850
+ const out = {};
851
+ for (const key of proj.pick) {
852
+ if (key in row)
853
+ out[key] = row[key];
854
+ }
855
+ return out;
856
+ }
857
+ if (proj.omit) {
858
+ const out = { ...row };
859
+ for (const key of proj.omit)
860
+ delete out[key];
861
+ return out;
862
+ }
863
+ return row;
694
864
  }
695
865
  /** Translate a Prisma relation include payload into a Turbine `WithOptions`. */
696
866
  function translateWithOption(ctx, mm, turbineRel, val) {
@@ -709,10 +879,14 @@ function translateWithOption(ctx, mm, turbineRel, val) {
709
879
  if (val.skip !== undefined) {
710
880
  throw new errors_js_1.UnsupportedFeatureError('skip (offset) on a nested relation include', 'prisma-compat', "Turbine's `with` clause has no offset, page the relation with a separate query.");
711
881
  }
712
- if (target && (val.select !== undefined || val.include !== undefined)) {
882
+ if (target && (val.select !== undefined || val.include !== undefined || val.omit !== undefined)) {
713
883
  const proj = translateProjection(ctx, target, val);
714
884
  if (proj.select)
715
885
  opt.select = proj.select;
886
+ // Same silent drop as the top level: a nested `omit` names the sensitive
887
+ // column on the CHILD rows, which are just as exposed.
888
+ if (proj.omit)
889
+ opt.omit = proj.omit;
716
890
  if (proj.with)
717
891
  opt.with = proj.with;
718
892
  }
@@ -759,6 +933,8 @@ function translateReadArgs(ctx, mm, prismaArgs, kind) {
759
933
  const proj = translateProjection(ctx, mm, prismaArgs);
760
934
  if (proj.select)
761
935
  t.select = proj.select;
936
+ if (proj.omit)
937
+ t.omit = proj.omit;
762
938
  if (proj.with)
763
939
  t.with = proj.with;
764
940
  if (Array.isArray(prismaArgs.distinct)) {
@@ -1520,15 +1696,22 @@ function makeDelegate(ctx, mm, getQI, runInTx) {
1520
1696
  findUnique: (args) => defer('findUnique', args, () => translateReadArgs(ctx, mm, requireWhere(args, 'findUnique'), 'unique'), (qi, t) => qi.findUnique(t).then((r) => reshapeRowOrNull(ctx, mm, r)), { build: (qi, t) => qi.buildFindUnique(t), reshape: (raw) => reshapeRowOrNull(ctx, mm, raw) }),
1521
1697
  findFirstOrThrow: (args = {}) => defer('findFirstOrThrow', args, () => translateReadArgs(ctx, mm, args, 'first'), (qi, t) => qi.findFirstOrThrow(t).then((r) => reshapeRow(ctx, mm, r)), { build: (qi, t) => qi.buildFindFirstOrThrow(t), reshape: (raw) => reshapeRow(ctx, mm, raw) }),
1522
1698
  findUniqueOrThrow: (args) => defer('findUniqueOrThrow', args, () => translateReadArgs(ctx, mm, requireWhere(args, 'findUniqueOrThrow'), 'unique'), (qi, t) => qi.findUniqueOrThrow(t).then((r) => reshapeRow(ctx, mm, r)), { build: (qi, t) => qi.buildFindUniqueOrThrow(t), reshape: (raw) => reshapeRow(ctx, mm, raw) }),
1523
- create: (args) => defer('create', args, () => {
1524
- const t = { data: translateWriteData(ctx, mm, applyCreateDefaults(mm, args.data)) };
1525
- (0, index_js_1.applyNativeOptions)(index_js_1.CREATE_OPTIONS, args, t);
1526
- return t;
1527
- }, (qi, t) => qi.create(t).then((r) => reshapeRow(ctx, mm, r)), {
1528
- build: (qi, t) => qi.buildCreate(t),
1529
- reshape: (raw) => reshapeRow(ctx, mm, raw),
1530
- nested: (t) => hasNestedKeys(ctx, mm, t.data),
1531
- }),
1699
+ create: (args) => {
1700
+ // Resolved inside the translate thunk below (so it validates before the
1701
+ // write runs and stays deferred), read back by every result path.
1702
+ let proj = NO_WRITE_PROJECTION;
1703
+ const shape = (raw) => applyWriteProjection(proj, reshapeRow(ctx, mm, raw));
1704
+ return defer('create', args, () => {
1705
+ proj = resolveWriteProjection(ctx, mm, 'create', args);
1706
+ const t = { data: translateWriteData(ctx, mm, applyCreateDefaults(mm, args.data)) };
1707
+ (0, index_js_1.applyNativeOptions)(index_js_1.CREATE_OPTIONS, args, t);
1708
+ return t;
1709
+ }, (qi, t) => qi.create(t).then(shape), {
1710
+ build: (qi, t) => qi.buildCreate(t),
1711
+ reshape: shape,
1712
+ nested: (t) => hasNestedKeys(ctx, mm, t.data),
1713
+ });
1714
+ },
1532
1715
  createMany: (args) => defer('createMany', args, () => {
1533
1716
  const data = args.data;
1534
1717
  const rows = Array.isArray(data)
@@ -1556,28 +1739,33 @@ function makeDelegate(ctx, mm, getQI, runInTx) {
1556
1739
  nested: (t) => createManyRunsOf(t).length > 1,
1557
1740
  execInTx: (table, t) => createManyByRun(table(mm.table), t, createManyRunsOf(t)),
1558
1741
  }),
1559
- update: (args) => defer('update', args, () => {
1560
- const a = requireWhere(args, 'update');
1561
- const t = {
1562
- where: translateWhere(ctx, mm, a.where),
1563
- data: translateWriteData(ctx, mm, applyUpdateTouch(mm, a.data)),
1564
- };
1565
- (0, index_js_1.applyNativeOptions)(index_js_1.UPDATE_OPTIONS, a, t);
1566
- // `optimisticLock.field` is a FIELD NAME, so it is renamed rather than
1567
- // copied: a blind passthrough would send the Prisma spelling into core
1568
- // and break on any model whose column is `@map`ped.
1569
- if (isPlainObject(a.optimisticLock)) {
1570
- t.optimisticLock = {
1571
- ...a.optimisticLock,
1572
- field: renameField(mm, String(a.optimisticLock.field)),
1742
+ update: (args) => {
1743
+ let proj = NO_WRITE_PROJECTION;
1744
+ const shape = (raw) => applyWriteProjection(proj, reshapeRow(ctx, mm, raw));
1745
+ return defer('update', args, () => {
1746
+ const a = requireWhere(args, 'update');
1747
+ proj = resolveWriteProjection(ctx, mm, 'update', a);
1748
+ const t = {
1749
+ where: translateWhere(ctx, mm, a.where),
1750
+ data: translateWriteData(ctx, mm, applyUpdateTouch(mm, a.data)),
1573
1751
  };
1574
- }
1575
- return t;
1576
- }, (qi, t) => qi.update(t).then((r) => reshapeRow(ctx, mm, r)), {
1577
- build: (qi, t) => qi.buildUpdate(t),
1578
- reshape: (raw) => reshapeRow(ctx, mm, raw),
1579
- nested: (t) => hasNestedKeys(ctx, mm, t.data),
1580
- }),
1752
+ (0, index_js_1.applyNativeOptions)(index_js_1.UPDATE_OPTIONS, a, t);
1753
+ // `optimisticLock.field` is a FIELD NAME, so it is renamed rather than
1754
+ // copied: a blind passthrough would send the Prisma spelling into core
1755
+ // and break on any model whose column is `@map`ped.
1756
+ if (isPlainObject(a.optimisticLock)) {
1757
+ t.optimisticLock = {
1758
+ ...a.optimisticLock,
1759
+ field: renameField(mm, String(a.optimisticLock.field)),
1760
+ };
1761
+ }
1762
+ return t;
1763
+ }, (qi, t) => qi.update(t).then(shape), {
1764
+ build: (qi, t) => qi.buildUpdate(t),
1765
+ reshape: shape,
1766
+ nested: (t) => hasNestedKeys(ctx, mm, t.data),
1767
+ });
1768
+ },
1581
1769
  updateMany: (args) => defer('updateMany', args, () => {
1582
1770
  const a = args;
1583
1771
  refuseRowLimit(modelName(ctx, mm), 'updateMany', a);
@@ -1590,15 +1778,20 @@ function makeDelegate(ctx, mm, getQI, runInTx) {
1590
1778
  // row, and an explicit `allowFullTableScan: false` must not be able to
1591
1779
  // turn that parity into a thrown empty-where guard.
1592
1780
  if (a.where === undefined)
1593
- t.allowFullTableScan = true;
1781
+ t.allowFullTableScan = index_js_1.UNSAFE;
1594
1782
  return t;
1595
1783
  }, (qi, t) => qi.updateMany(t), { build: (qi, t) => qi.buildUpdateMany(t), reshape: (raw) => raw }),
1596
- delete: (args) => defer('delete', args, () => {
1597
- const a = requireWhere(args, 'delete');
1598
- const t = { where: translateWhere(ctx, mm, a.where) };
1599
- (0, index_js_1.applyNativeOptions)(index_js_1.DELETE_OPTIONS, a, t);
1600
- return t;
1601
- }, (qi, t) => qi.delete(t).then((r) => reshapeRow(ctx, mm, r)), { build: (qi, t) => qi.buildDelete(t), reshape: (raw) => reshapeRow(ctx, mm, raw) }),
1784
+ delete: (args) => {
1785
+ let proj = NO_WRITE_PROJECTION;
1786
+ const shape = (raw) => applyWriteProjection(proj, reshapeRow(ctx, mm, raw));
1787
+ return defer('delete', args, () => {
1788
+ const a = requireWhere(args, 'delete');
1789
+ proj = resolveWriteProjection(ctx, mm, 'delete', a);
1790
+ const t = { where: translateWhere(ctx, mm, a.where) };
1791
+ (0, index_js_1.applyNativeOptions)(index_js_1.DELETE_OPTIONS, a, t);
1792
+ return t;
1793
+ }, (qi, t) => qi.delete(t).then(shape), { build: (qi, t) => qi.buildDelete(t), reshape: shape });
1794
+ },
1602
1795
  deleteMany: (args = {}) => defer('deleteMany', args, () => {
1603
1796
  const a = args;
1604
1797
  refuseRowLimit(modelName(ctx, mm), 'deleteMany', a);
@@ -1606,40 +1799,42 @@ function makeDelegate(ctx, mm, getQI, runInTx) {
1606
1799
  (0, index_js_1.applyNativeOptions)(index_js_1.DELETE_MANY_OPTIONS, a, t);
1607
1800
  // LAST, so it wins. See the same note on updateMany.
1608
1801
  if (a.where === undefined)
1609
- t.allowFullTableScan = true;
1802
+ t.allowFullTableScan = index_js_1.UNSAFE;
1610
1803
  return t;
1611
1804
  }, (qi, t) => qi.deleteMany(t), { build: (qi, t) => qi.buildDeleteMany(t), reshape: (raw) => raw }),
1612
- upsert: (args) => defer('upsert', args, () => {
1613
- const a = requireWhere(args, 'upsert');
1614
- const t = {
1615
- where: translateWhere(ctx, mm, a.where),
1616
- create: translateWriteData(ctx, mm, applyCreateDefaults(mm, a.create)),
1617
- update: translateWriteData(ctx, mm, applyUpdateTouch(mm, a.update)),
1618
- };
1619
- (0, index_js_1.applyNativeOptions)(index_js_1.UPSERT_OPTIONS, a, t);
1620
- return t;
1621
- }, (qi, t) => {
1622
- // Native ON CONFLICT upsert is only Prisma-equivalent when the where
1623
- // key values equal the create values AND no nested write data is
1624
- // present; otherwise emulate Prisma's lookup-first atomically.
1625
- if (upsertKeysMatch(t) && !hasNestedKeys(ctx, mm, t.create) && !hasNestedKeys(ctx, mm, t.update)) {
1626
- return qi.upsert(t).then((r) => reshapeRow(ctx, mm, r));
1627
- }
1628
- return runInTx(async (table) => {
1629
- const row = await upsertLookupFirst(table(mm.table), t);
1630
- return reshapeRow(ctx, mm, row);
1631
- });
1632
- }, {
1633
- build: (qi, t) => qi.buildUpsert(t),
1634
- reshape: (raw) => reshapeRow(ctx, mm, raw),
1635
- nested: (t) => !upsertKeysMatch(t) || hasNestedKeys(ctx, mm, t.create) || hasNestedKeys(ctx, mm, t.update),
1636
- execInTx: async (table, t) => {
1805
+ upsert: (args) => {
1806
+ let proj = NO_WRITE_PROJECTION;
1807
+ const shape = (raw) => applyWriteProjection(proj, reshapeRow(ctx, mm, raw));
1808
+ return defer('upsert', args, () => {
1809
+ const a = requireWhere(args, 'upsert');
1810
+ proj = resolveWriteProjection(ctx, mm, 'upsert', a);
1811
+ const t = {
1812
+ where: translateWhere(ctx, mm, a.where),
1813
+ create: translateWriteData(ctx, mm, applyCreateDefaults(mm, a.create)),
1814
+ update: translateWriteData(ctx, mm, applyUpdateTouch(mm, a.update)),
1815
+ };
1816
+ (0, index_js_1.applyNativeOptions)(index_js_1.UPSERT_OPTIONS, a, t);
1817
+ return t;
1818
+ }, (qi, t) => {
1819
+ // Native ON CONFLICT upsert is only Prisma-equivalent when the where
1820
+ // key values equal the create values AND no nested write data is
1821
+ // present; otherwise emulate Prisma's lookup-first atomically.
1637
1822
  if (upsertKeysMatch(t) && !hasNestedKeys(ctx, mm, t.create) && !hasNestedKeys(ctx, mm, t.update)) {
1638
- return reshapeRow(ctx, mm, await table(mm.table).upsert(t));
1823
+ return qi.upsert(t).then(shape);
1639
1824
  }
1640
- return reshapeRow(ctx, mm, await upsertLookupFirst(table(mm.table), t));
1641
- },
1642
- }),
1825
+ return runInTx(async (table) => shape(await upsertLookupFirst(table(mm.table), t)));
1826
+ }, {
1827
+ build: (qi, t) => qi.buildUpsert(t),
1828
+ reshape: shape,
1829
+ nested: (t) => !upsertKeysMatch(t) || hasNestedKeys(ctx, mm, t.create) || hasNestedKeys(ctx, mm, t.update),
1830
+ execInTx: async (table, t) => {
1831
+ if (upsertKeysMatch(t) && !hasNestedKeys(ctx, mm, t.create) && !hasNestedKeys(ctx, mm, t.update)) {
1832
+ return shape(await table(mm.table).upsert(t));
1833
+ }
1834
+ return shape(await upsertLookupFirst(table(mm.table), t));
1835
+ },
1836
+ });
1837
+ },
1643
1838
  count: (args = {}) => defer('count', args, () => {
1644
1839
  const t = {};
1645
1840
  (0, index_js_1.applyNativeOptions)(index_js_1.COUNT_OPTIONS, args, t);
@@ -18,7 +18,7 @@ import type { BuilderCtx } from './where.js';
18
18
  * projection, and a value-returning aggregate is a projection by another name:
19
19
  * `groupBy({ by: ['email'] })` emits one row per distinct plaintext email, and
20
20
  * `_min`/`_max` return a stored cell verbatim. Both therefore REQUIRE the same
21
- * `includePii: true` opt-in reads use.
21
+ * `includePii` opt-in (the UNSAFE sentinel) that reads use.
22
22
  *
23
23
  * Deliberately NOT gated: `_count` (a count, never a value), `_sum` / `_avg`
24
24
  * (a computed total across many rows, not a stored cell), and `where` /
@@ -54,6 +54,7 @@ exports.buildAggregate = buildAggregate;
54
54
  const errors_js_1 = require("../errors.js");
55
55
  const schema_js_1 = require("../schema.js");
56
56
  const filters_js_1 = require("./filters.js");
57
+ const types_js_1 = require("./types.js");
57
58
  const utils_js_1 = require("./utils.js");
58
59
  const whereMod = __importStar(require("./where.js"));
59
60
  /**
@@ -62,7 +63,7 @@ const whereMod = __importStar(require("./where.js"));
62
63
  * projection, and a value-returning aggregate is a projection by another name:
63
64
  * `groupBy({ by: ['email'] })` emits one row per distinct plaintext email, and
64
65
  * `_min`/`_max` return a stored cell verbatim. Both therefore REQUIRE the same
65
- * `includePii: true` opt-in reads use.
66
+ * `includePii` opt-in (the UNSAFE sentinel) that reads use.
66
67
  *
67
68
  * Deliberately NOT gated: `_count` (a count, never a value), `_sum` / `_avg`
68
69
  * (a computed total across many rows, not a stored cell), and `where` /
@@ -80,7 +81,8 @@ function assertAggregatePiiOptIn(table, meta, field, column, usage, includePii)
80
81
  return;
81
82
  throw new errors_js_1.ValidationError(`[turbine] ${usage} on column "${field}" of table "${table}" is refused: that column is ` +
82
83
  'PII-tagged (`pii: true`), and this aggregate returns its stored values, which are excluded ' +
83
- 'from every default projection. Pass `includePii: true` on this call to opt in. ' +
84
+ 'from every default projection. Pass `includePii: UNSAFE` on this call to opt in ' +
85
+ "(import { UNSAFE } from 'turbine-orm'). " +
84
86
  '`_count` over a PII column (a count, not a value) and `where` / `orderBy` / `having` on PII ' +
85
87
  'columns need no opt-in.');
86
88
  }
@@ -93,7 +95,11 @@ function buildGroupBy(qi, args) {
93
95
  }
94
96
  }
95
97
  }
96
- qi.currentSkip = args.skipGlobalFilters;
98
+ qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
99
+ // Resolve the PII opt-in ONCE, here, so the sentinel check runs on every
100
+ // groupBy (including one whose `by` names no PII column) rather than only on
101
+ // the paths that happen to consult it.
102
+ const includePii = (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii');
97
103
  const gbWhere = whereMod.mergeGlobalFilter(qi, args.where);
98
104
  const { sql: whereSql, params } = gbWhere
99
105
  ? whereMod.buildWhere(qi, gbWhere)
@@ -143,7 +149,7 @@ function buildGroupBy(qi, args) {
143
149
  for (const entry of args.by) {
144
150
  if (typeof entry === 'string') {
145
151
  const col = qi.toColumn(entry);
146
- assertAggregatePiiOptIn(qi.table, meta, entry, col, 'groupBy `by` key', args.includePii);
152
+ assertAggregatePiiOptIn(qi.table, meta, entry, col, 'groupBy `by` key', includePii);
147
153
  claimResultKey(entry, `column "${col}"`);
148
154
  // The emitted output column is the snake_case name; claim it too (when
149
155
  // it differs from the result key) so a JSON alias like 'created_at'
@@ -158,7 +164,7 @@ function buildGroupBy(qi, args) {
158
164
  }
159
165
  else {
160
166
  const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
161
- assertAggregatePiiOptIn(qi.table, meta, entry.field, col, 'groupBy JSON `by` key', args.includePii);
167
+ assertAggregatePiiOptIn(qi.table, meta, entry.field, col, 'groupBy JSON `by` key', includePii);
162
168
  params.push(whereMod.jsonPathParam(qi, entry.path));
163
169
  const extract = qi.dialect.buildJsonPathExtract(qi.q(col), qi.p(params.length));
164
170
  const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
@@ -226,7 +232,7 @@ function buildGroupBy(qi, args) {
226
232
  if (target === true) {
227
233
  const col = qi.toColumn(key);
228
234
  if (aggKey === '_min' || aggKey === '_max') {
229
- assertAggregatePiiOptIn(qi.table, meta, key, col, `groupBy ${aggKey}`, args.includePii);
235
+ assertAggregatePiiOptIn(qi.table, meta, key, col, `groupBy ${aggKey}`, includePii);
230
236
  }
231
237
  // Aggregate output aliases share the same output-name namespace as
232
238
  // the group keys: `_sum: { totalPrice: true, total_price: {json} }`
@@ -240,7 +246,7 @@ function buildGroupBy(qi, args) {
240
246
  }
241
247
  const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
242
248
  if (aggKey === '_min' || aggKey === '_max') {
243
- assertAggregatePiiOptIn(qi.table, meta, target.field, col, `groupBy ${aggKey} JSON target`, args.includePii);
249
+ assertAggregatePiiOptIn(qi.table, meta, target.field, col, `groupBy ${aggKey} JSON target`, includePii);
244
250
  }
245
251
  const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
246
252
  if (alwaysNumeric && target.type === 'text') {
@@ -412,6 +418,9 @@ function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
412
418
  throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${qi.table}": _count is not selected. ` +
413
419
  `Orderable keys: ${validKeys()}.`);
414
420
  }
421
+ // Refuse a direction that is neither asc nor desc BEFORE normalizeOrderBy,
422
+ // whose `=== 'desc' ? DESC : ASC` would silently sort ascending.
423
+ (0, types_js_1.assertOrderDirection)(value, `groupBy orderBy "_count" on table "${qi.table}"`);
415
424
  const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
416
425
  parts.push(`${expr} ${dir}${qi.nullsSuffix(nulls)}`);
417
426
  continue;
@@ -429,6 +438,7 @@ function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
429
438
  throw new errors_js_1.ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${qi.table}": ` +
430
439
  `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
431
440
  }
441
+ (0, types_js_1.assertOrderDirection)(dirSpec, `groupBy orderBy "${key}.${field}" on table "${qi.table}"`);
432
442
  const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirSpec);
433
443
  parts.push(`${expr} ${dir}${qi.nullsSuffix(nulls)}`);
434
444
  }
@@ -440,6 +450,7 @@ function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
440
450
  throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${qi.table}". ` +
441
451
  `Orderable keys: ${validKeys()}.`);
442
452
  }
453
+ (0, types_js_1.assertOrderDirection)(value, `groupBy orderBy "${key}" on table "${qi.table}"`);
443
454
  const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
444
455
  parts.push(`${expr} ${dir}${qi.nullsSuffix(nulls)}`);
445
456
  }
@@ -508,6 +519,7 @@ function buildDistinctOnSource(qi, distinctOn, whereSql, params) {
508
519
  'sort specs, and JSON-path orderings only.');
509
520
  }
510
521
  const col = qi.resolveOrderByColumn(qi.table, qi.tableMeta, key);
522
+ (0, types_js_1.assertOrderDirection)(value, `groupBy distinctOn.orderBy "${key}" on table "${qi.table}"`);
511
523
  const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
512
524
  orderParts.push(`${qi.q(col)} ${dir}${qi.nullsSuffix(nulls)}`);
513
525
  }
@@ -803,7 +815,9 @@ function temporalAggValue(qi, col, value) {
803
815
  return value === '-infinity' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
804
816
  }
805
817
  function buildAggregate(qi, args) {
806
- qi.currentSkip = args.skipGlobalFilters;
818
+ qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
819
+ // Resolved once, up front: see buildGroupBy.
820
+ const includePii = (0, types_js_1.resolveUnsafeFlag)(args.includePii, 'includePii');
807
821
  const aggWhere = whereMod.mergeGlobalFilter(qi, args.where);
808
822
  const { sql: whereSql, params } = aggWhere
809
823
  ? whereMod.buildWhere(qi, aggWhere)
@@ -874,7 +888,7 @@ function buildAggregate(qi, args) {
874
888
  for (const [field, enabled] of Object.entries(args._min)) {
875
889
  if (enabled) {
876
890
  const col = qi.toColumn(field);
877
- assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _min', args.includePii);
891
+ assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _min', includePii);
878
892
  selectExprs.push(`MIN(${qi.q(col)}) AS ${qi.q(`_min_${col}`)}`);
879
893
  }
880
894
  }
@@ -884,7 +898,7 @@ function buildAggregate(qi, args) {
884
898
  for (const [field, enabled] of Object.entries(args._max)) {
885
899
  if (enabled) {
886
900
  const col = qi.toColumn(field);
887
- assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _max', args.includePii);
901
+ assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _max', includePii);
888
902
  selectExprs.push(`MAX(${qi.q(col)}) AS ${qi.q(`_max_${col}`)}`);
889
903
  }
890
904
  }
@@ -56,7 +56,7 @@
56
56
  import type pg from 'pg';
57
57
  import { type RelationDef, type SchemaMetadata, type TableMetadata } from '../schema.js';
58
58
  import type { ReselectExecutor } from './builder.js';
59
- import type { SkipGlobalFilters, WithClause, WithCount } from './types.js';
59
+ import type { SkipGlobalFilters, Unsafe, WithClause, WithCount } from './types.js';
60
60
  /**
61
61
  * A DeferredQuery, minimally typed for what the loader consumes. Kept local to
62
62
  * avoid a value import of builder.ts (which imports this module).
@@ -104,9 +104,14 @@ export interface RelationLoadContext {
104
104
  /**
105
105
  * The query's `includePii` opt-in, threaded onto every child `buildFindMany`
106
106
  * so a batched relation load excludes (or includes) PII-tagged columns exactly
107
- * as the join strategy does at every nested level. Default `false`.
107
+ * as the join strategy does at every nested level. Absent by default.
108
+ *
109
+ * Typed as the SENTINEL, not a boolean, and that is a contract not a style
110
+ * choice: this value is copied verbatim onto child `FindManyArgs`, where a
111
+ * plain `true` is refused as a privilege escalation. A boolean here would
112
+ * type-check and then throw on the first relation follow-up.
108
113
  */
109
- includePii?: boolean;
114
+ includePii?: Unsafe;
110
115
  /**
111
116
  * Render `table`'s global filter against `alias` for a raw follow-up query
112
117
  * (the batched `_count`), numbering its `$n` placeholders AFTER
@@ -128,7 +133,7 @@ export interface RelationLoadContext {
128
133
  * keep the `select: undefined, omit: undefined` fast path and the emitted SQL
129
134
  * stays byte-identical.
130
135
  */
131
- export declare function defaultProjectionFields(meta: TableMetadata, includePii: boolean | undefined): {
136
+ export declare function defaultProjectionFields(meta: TableMetadata, includePii: boolean | Unsafe | undefined): {
132
137
  hidden: ReadonlySet<string>;
133
138
  visible: string[];
134
139
  } | undefined;
@@ -86,7 +86,10 @@ const MAX_DEPTH = 10;
86
86
  * keep the `select: undefined, omit: undefined` fast path and the emitted SQL
87
87
  * stays byte-identical.
88
88
  */
89
- function defaultProjectionFields(meta, includePii) {
89
+ function defaultProjectionFields(meta,
90
+ // Either the already-resolved boolean (top-level callers) or the raw sentinel
91
+ // (the relation-load context); both are only ever tested for truthiness.
92
+ includePii) {
90
93
  if (includePii)
91
94
  return undefined;
92
95
  const hidden = new Set();