turbine-orm 0.60.1 → 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 (84) hide show
  1. package/README.md +71 -27
  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 +374 -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 +248 -11
  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-submittable.js +26 -3
  21. package/dist/cjs/pipeline.js +15 -2
  22. package/dist/cjs/powql.d.ts +12 -0
  23. package/dist/cjs/powql.js +46 -21
  24. package/dist/cjs/prisma-compat.d.ts +15 -5
  25. package/dist/cjs/prisma-compat.js +273 -78
  26. package/dist/cjs/query/aggregates.d.ts +1 -1
  27. package/dist/cjs/query/aggregates.js +24 -10
  28. package/dist/cjs/query/batched-loader.d.ts +9 -4
  29. package/dist/cjs/query/batched-loader.js +4 -1
  30. package/dist/cjs/query/builder.d.ts +47 -0
  31. package/dist/cjs/query/builder.js +149 -21
  32. package/dist/cjs/query/index.d.ts +3 -1
  33. package/dist/cjs/query/index.js +7 -1
  34. package/dist/cjs/query/option-surface.d.ts +11 -0
  35. package/dist/cjs/query/option-surface.js +13 -0
  36. package/dist/cjs/query/relations.d.ts +8 -0
  37. package/dist/cjs/query/relations.js +21 -1
  38. package/dist/cjs/query/types.d.ts +152 -18
  39. package/dist/cjs/query/types.js +212 -1
  40. package/dist/cjs/query/where.d.ts +3 -3
  41. package/dist/cjs/query/where.js +8 -2
  42. package/dist/cjs/query/writes.js +10 -9
  43. package/dist/cli/config.d.ts +40 -0
  44. package/dist/cli/config.js +73 -2
  45. package/dist/cli/index.d.ts +85 -1
  46. package/dist/cli/index.js +373 -27
  47. package/dist/cli/mcp.d.ts +8 -0
  48. package/dist/cli/mcp.js +448 -29
  49. package/dist/cli/pii-tags.d.ts +64 -9
  50. package/dist/cli/pii-tags.js +217 -39
  51. package/dist/cli/studio-ui.generated.js +1 -1
  52. package/dist/cli/studio.d.ts +23 -0
  53. package/dist/cli/studio.js +125 -53
  54. package/dist/cli/ui.d.ts +15 -1
  55. package/dist/cli/ui.js +18 -4
  56. package/dist/client.js +250 -13
  57. package/dist/errors.d.ts +38 -1
  58. package/dist/errors.js +234 -23
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.js +5 -2
  61. package/dist/pipeline-submittable.js +26 -3
  62. package/dist/pipeline.js +15 -2
  63. package/dist/powql.d.ts +12 -0
  64. package/dist/powql.js +46 -21
  65. package/dist/prisma-compat.d.ts +15 -5
  66. package/dist/prisma-compat.js +274 -79
  67. package/dist/query/aggregates.d.ts +1 -1
  68. package/dist/query/aggregates.js +24 -10
  69. package/dist/query/batched-loader.d.ts +9 -4
  70. package/dist/query/batched-loader.js +4 -1
  71. package/dist/query/builder.d.ts +47 -0
  72. package/dist/query/builder.js +148 -21
  73. package/dist/query/index.d.ts +3 -1
  74. package/dist/query/index.js +2 -0
  75. package/dist/query/option-surface.d.ts +11 -0
  76. package/dist/query/option-surface.js +13 -0
  77. package/dist/query/relations.d.ts +8 -0
  78. package/dist/query/relations.js +21 -1
  79. package/dist/query/types.d.ts +152 -18
  80. package/dist/query/types.js +207 -2
  81. package/dist/query/where.d.ts +3 -3
  82. package/dist/query/where.js +8 -2
  83. package/dist/query/writes.js +10 -9
  84. package/package.json +13 -3
@@ -40,6 +40,11 @@
40
40
  * by this layer, and a key that is neither a Prisma arg nor a turbine option
41
41
  * gets a dev-mode warning instead of vanishing (see
42
42
  * {@link PRISMA_ARG_KEYS} and `warnUnknownQueryOptions`).
43
+ * The three PRIVILEGE options among them (`skipGlobalFilters`, `includePii`,
44
+ * `allowFullTableScan`) are forwarded VERBATIM, which is what makes them safe
45
+ * here: core accepts only the `UNSAFE` sentinel, so a Prisma-shaped args
46
+ * object carrying `includePii: true` reaches core and is refused there, and
47
+ * this layer never needs its own copy of that rule.
43
48
  *
44
49
  * ## What it deliberately does NOT do (documented divergences)
45
50
  *
@@ -75,11 +80,16 @@
75
80
  * - **`limit` on `updateMany` / `deleteMany`** (Prisma 6.7+) throws. Turbine has
76
81
  * no row-bounded mass mutation, and dropping a SAFETY BOUND with a warning
77
82
  * would turn "change at most 10 rows" into "change every matching row".
78
- * - **Write projections** (`select` / `include` / `omit` on
79
- * create/update/delete/upsert), **`select` on `count`**, and
80
- * **`orderBy` / `cursor` / `take` / `skip` on `aggregate`** are accepted and
81
- * IGNORED (they are legitimate Prisma, so they never warn); the full row / a
82
- * plain number comes back.
83
+ * - **Write projections**: `select` / `omit` on create/update/delete/upsert
84
+ * narrow the RETURNED OBJECT (see {@link resolveWriteProjection}); they do
85
+ * NOT narrow the statement's `RETURNING` list, so the column still crosses
86
+ * the wire, and `pii: true` remains the SQL-level control. A relation key in
87
+ * either (or any `include` on a write) THROWS, since a write returns its own
88
+ * row only. `omit` on READS is translated to core's own `omit`, see
89
+ * {@link translateOmit}.
90
+ * - **`select` on `count`** and **`orderBy` / `cursor` / `take` / `skip` on
91
+ * `aggregate`** are accepted and IGNORED (they are legitimate Prisma, so they
92
+ * never warn); a plain number / the aggregate comes back.
83
93
  *
84
94
  * ## Type dependencies (0.41.0)
85
95
  *
@@ -112,7 +122,7 @@
112
122
  import { TurbineError, TurbineErrorCode, UnsupportedFeatureError, ValidationError, wrapPgError } from './errors.js';
113
123
  import { createManyShapeRuns } from './nested-write.js';
114
124
  import { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
115
- import { AGGREGATE_OPTIONS, applyNativeOptions, COUNT_OPTIONS, CREATE_MANY_OPTIONS, CREATE_OPTIONS, DELETE_MANY_OPTIONS, DELETE_OPTIONS, FIND_MANY_OPTIONS, FIND_UNIQUE_OPTIONS, GROUP_BY_OPTIONS, optionKeysOfKind, UPDATE_MANY_OPTIONS, UPDATE_OPTIONS, UPSERT_OPTIONS, } from './query/index.js';
125
+ import { AGGREGATE_OPTIONS, applyNativeOptions, COUNT_OPTIONS, CREATE_MANY_OPTIONS, CREATE_OPTIONS, DELETE_MANY_OPTIONS, DELETE_OPTIONS, FIND_MANY_OPTIONS, FIND_UNIQUE_OPTIONS, GROUP_BY_OPTIONS, optionKeysOfKind, UNSAFE, UPDATE_MANY_OPTIONS, UPDATE_OPTIONS, UPSERT_OPTIONS, } from './query/index.js';
116
126
  import { suggestKey } from './query/utils.js';
117
127
  import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
118
128
  // ---------------------------------------------------------------------------
@@ -601,16 +611,53 @@ function mapTake(take) {
601
611
  return take;
602
612
  }
603
613
  /**
604
- * Translate Prisma `include` / `select` into Turbine `{ select, with }`.
605
- * `include` keeps all scalars and adds relations; `select` narrows scalars and
606
- * may also pull relations + `_count`. The two are mutually exclusive.
614
+ * Translate a Prisma `omit` (scalar field names → true) into Turbine's `omit`.
615
+ *
616
+ * This used to be DROPPED silently, which made it a data-exposure bug rather
617
+ * than an ergonomics one: `omit` is the idiom for a sensitive-but-untagged
618
+ * column (`passwordHash`, `resetToken`), so a caller who asked for it got the
619
+ * column back anyway, in a response they believed was already filtered. Turbine
620
+ * core has supported `omit` on reads all along; only this translation was
621
+ * missing.
622
+ *
623
+ * A relation name is refused rather than forwarded: core's `omit` narrows the
624
+ * SCALAR projection, so `omit: { posts: true }` would have compiled to nothing
625
+ * and dropped the caller's intent a second time. Prisma rejects it too.
626
+ */
627
+ function translateOmit(ctx, mm, omit) {
628
+ const out = {};
629
+ let any = false;
630
+ for (const [key, val] of Object.entries(omit)) {
631
+ if (val === false || val == null)
632
+ continue;
633
+ if (mm.relations[key]) {
634
+ throw new ValidationError(`[turbine] prisma-compat: \`omit\` on model "${modelName(ctx, mm)}" takes scalar fields, but "${key}" ` +
635
+ 'is a relation. Leave the relation out of `include` instead.');
636
+ }
637
+ out[renameField(mm, key)] = true;
638
+ any = true;
639
+ }
640
+ return any ? out : undefined;
641
+ }
642
+ /**
643
+ * Translate Prisma `include` / `select` / `omit` into Turbine
644
+ * `{ select, omit, with }`. `include` keeps all scalars and adds relations;
645
+ * `select` narrows scalars and may also pull relations + `_count`. `include`
646
+ * and `select` are mutually exclusive, and so are `select` and `omit` (a
647
+ * narrowed projection minus fields is ambiguous; Prisma refuses the pair too).
607
648
  */
608
649
  function translateProjection(ctx, mm, args) {
609
650
  const include = args.include;
610
651
  const select = args.select;
652
+ const omitArg = args.omit;
611
653
  if (include && select) {
612
654
  throw new ValidationError('[turbine] prisma-compat: `include` and `select` are mutually exclusive.');
613
655
  }
656
+ if (select && omitArg) {
657
+ throw new ValidationError('[turbine] prisma-compat: `select` and `omit` are mutually exclusive. ' +
658
+ 'A `select` already lists exactly the fields you want.');
659
+ }
660
+ const omit = omitArg ? translateOmit(ctx, mm, omitArg) : undefined;
614
661
  const withClause = {};
615
662
  let hasWith = false;
616
663
  if (include) {
@@ -629,7 +676,7 @@ function translateProjection(ctx, mm, args) {
629
676
  withClause[rel.name] = translateWithOption(ctx, mm, rel.name, val);
630
677
  hasWith = true;
631
678
  }
632
- return { with: hasWith ? withClause : undefined };
679
+ return { omit, with: hasWith ? withClause : undefined };
633
680
  }
634
681
  if (select) {
635
682
  const scalar = {};
@@ -653,7 +700,130 @@ function translateProjection(ctx, mm, args) {
653
700
  }
654
701
  return { select: hasScalar ? scalar : undefined, with: hasWith ? withClause : undefined };
655
702
  }
656
- return {};
703
+ return { omit };
704
+ }
705
+ const NO_WRITE_PROJECTION = {};
706
+ /**
707
+ * Refuse a write-projection key that names no field on the model.
708
+ *
709
+ * Unlike a READ projection, this one never reaches core: `select` / `omit` on a
710
+ * write are applied to the already-returned object by
711
+ * {@link applyWriteProjection}, so nothing downstream can notice the name is
712
+ * wrong. A typo therefore failed silently in BOTH directions, and the `omit`
713
+ * direction is the one that matters: `omit: { ssn: true }` misspelled as
714
+ * `snn` returned the column, and the call site that asked for it hidden had no
715
+ * signal at all. (`pick` failed the other way, quietly dropping the field from
716
+ * the result.)
717
+ *
718
+ * The read path has never had this hole: core validates every projection key
719
+ * and throws E003 on an unknown one, which is the behaviour a caller has
720
+ * already observed on `findMany` before writing the same clause on `create`.
721
+ * So this is not a new rule, it is the read rule reaching the one surface that
722
+ * bypassed it. Errors match core's shape, including the suggestion.
723
+ */
724
+ function assertWriteProjectionField(ctx, mm, op, clause, key) {
725
+ if (Object.hasOwn(mm.fields, key))
726
+ return;
727
+ const known = Object.keys(mm.fields);
728
+ const suggestion = suggestKey(key, new Set(known));
729
+ throw new ValidationError(`[turbine] prisma-compat: unknown field "${key}" in \`${clause}\` on ${modelName(ctx, mm)}.${op}().` +
730
+ (suggestion ? ` Did you mean "${suggestion}"?` : '') +
731
+ ` Known fields: ${known.join(', ') || '(none)'}.`);
732
+ }
733
+ /**
734
+ * Resolve `select` / `omit` / `include` on create / update / delete / upsert.
735
+ *
736
+ * All three used to be accepted and IGNORED here. For `omit` that is the exact
737
+ * data-exposure bug that was fixed for reads: `omit` is the idiom for a
738
+ * sensitive-but-untagged column, and because it demonstrably WORKS on
739
+ * `findMany`, a caller has positive evidence for assuming it works on `create`
740
+ * too. `select` is the same class in the other spelling (the caller asked for
741
+ * three fields and got the whole row).
742
+ *
743
+ * SCOPE, stated exactly rather than implied: this narrows the OBJECT this
744
+ * adapter returns. Core's write args carry no projection, so the column is
745
+ * still in the statement's `RETURNING` list and still crosses the wire. The
746
+ * SQL-level control is `pii: true` on the column, which is enforced in core and
747
+ * is unaffected by any of this.
748
+ *
749
+ * A relation (or `_count`) key THROWS instead: a write returns its own row and
750
+ * nothing else, so serving it is impossible, and the alternative is handing
751
+ * back a row whose relation property is silently `undefined`, which is the
752
+ * failure mode this whole round exists to remove.
753
+ */
754
+ function resolveWriteProjection(ctx, mm, op, args) {
755
+ if (!args)
756
+ return NO_WRITE_PROJECTION;
757
+ const include = args.include;
758
+ const select = args.select;
759
+ const omitArg = args.omit;
760
+ if (include && select) {
761
+ throw new ValidationError('[turbine] prisma-compat: `include` and `select` are mutually exclusive.');
762
+ }
763
+ if (select && omitArg) {
764
+ throw new ValidationError('[turbine] prisma-compat: `select` and `omit` are mutually exclusive. ' +
765
+ 'A `select` already lists exactly the fields you want.');
766
+ }
767
+ if (include) {
768
+ for (const [key, val] of Object.entries(include)) {
769
+ if (val === false || val == null)
770
+ continue;
771
+ throw new 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 ` +
772
+ 'separate findUnique inside the same $transaction.');
773
+ }
774
+ }
775
+ if (select) {
776
+ const pick = new Set();
777
+ for (const [key, val] of Object.entries(select)) {
778
+ if (val === false || val == null)
779
+ continue;
780
+ if (key === '_count' || mm.relations[key]) {
781
+ throw new 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 ` +
782
+ 'it back with a separate findUnique inside the same $transaction.');
783
+ }
784
+ assertWriteProjectionField(ctx, mm, op, 'select', key);
785
+ pick.add(key);
786
+ }
787
+ // An all-false `select` names nothing. Reads treat that as "no projection"
788
+ // (translateProjection returns select: undefined), so writes do too rather
789
+ // than inventing a second rule for input Prisma rejects anyway.
790
+ return pick.size ? { pick } : NO_WRITE_PROJECTION;
791
+ }
792
+ if (omitArg) {
793
+ const omit = new Set();
794
+ for (const [key, val] of Object.entries(omitArg)) {
795
+ if (val === false || val == null)
796
+ continue;
797
+ if (mm.relations[key]) {
798
+ throw new ValidationError(`[turbine] prisma-compat: \`omit\` on model "${modelName(ctx, mm)}" takes scalar fields, but "${key}" ` +
799
+ 'is a relation.');
800
+ }
801
+ assertWriteProjectionField(ctx, mm, op, 'omit', key);
802
+ omit.add(key);
803
+ }
804
+ return omit.size ? { omit } : NO_WRITE_PROJECTION;
805
+ }
806
+ return NO_WRITE_PROJECTION;
807
+ }
808
+ /** Apply a {@link WriteProjection} to one already-reshaped write result row. */
809
+ function applyWriteProjection(proj, row) {
810
+ if (!isPlainObject(row))
811
+ return row;
812
+ if (proj.pick) {
813
+ const out = {};
814
+ for (const key of proj.pick) {
815
+ if (key in row)
816
+ out[key] = row[key];
817
+ }
818
+ return out;
819
+ }
820
+ if (proj.omit) {
821
+ const out = { ...row };
822
+ for (const key of proj.omit)
823
+ delete out[key];
824
+ return out;
825
+ }
826
+ return row;
657
827
  }
658
828
  /** Translate a Prisma relation include payload into a Turbine `WithOptions`. */
659
829
  function translateWithOption(ctx, mm, turbineRel, val) {
@@ -672,10 +842,14 @@ function translateWithOption(ctx, mm, turbineRel, val) {
672
842
  if (val.skip !== undefined) {
673
843
  throw new UnsupportedFeatureError('skip (offset) on a nested relation include', 'prisma-compat', "Turbine's `with` clause has no offset, page the relation with a separate query.");
674
844
  }
675
- if (target && (val.select !== undefined || val.include !== undefined)) {
845
+ if (target && (val.select !== undefined || val.include !== undefined || val.omit !== undefined)) {
676
846
  const proj = translateProjection(ctx, target, val);
677
847
  if (proj.select)
678
848
  opt.select = proj.select;
849
+ // Same silent drop as the top level: a nested `omit` names the sensitive
850
+ // column on the CHILD rows, which are just as exposed.
851
+ if (proj.omit)
852
+ opt.omit = proj.omit;
679
853
  if (proj.with)
680
854
  opt.with = proj.with;
681
855
  }
@@ -722,6 +896,8 @@ function translateReadArgs(ctx, mm, prismaArgs, kind) {
722
896
  const proj = translateProjection(ctx, mm, prismaArgs);
723
897
  if (proj.select)
724
898
  t.select = proj.select;
899
+ if (proj.omit)
900
+ t.omit = proj.omit;
725
901
  if (proj.with)
726
902
  t.with = proj.with;
727
903
  if (Array.isArray(prismaArgs.distinct)) {
@@ -1483,15 +1659,22 @@ function makeDelegate(ctx, mm, getQI, runInTx) {
1483
1659
  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) }),
1484
1660
  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) }),
1485
1661
  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) }),
1486
- create: (args) => defer('create', args, () => {
1487
- const t = { data: translateWriteData(ctx, mm, applyCreateDefaults(mm, args.data)) };
1488
- applyNativeOptions(CREATE_OPTIONS, args, t);
1489
- return t;
1490
- }, (qi, t) => qi.create(t).then((r) => reshapeRow(ctx, mm, r)), {
1491
- build: (qi, t) => qi.buildCreate(t),
1492
- reshape: (raw) => reshapeRow(ctx, mm, raw),
1493
- nested: (t) => hasNestedKeys(ctx, mm, t.data),
1494
- }),
1662
+ create: (args) => {
1663
+ // Resolved inside the translate thunk below (so it validates before the
1664
+ // write runs and stays deferred), read back by every result path.
1665
+ let proj = NO_WRITE_PROJECTION;
1666
+ const shape = (raw) => applyWriteProjection(proj, reshapeRow(ctx, mm, raw));
1667
+ return defer('create', args, () => {
1668
+ proj = resolveWriteProjection(ctx, mm, 'create', args);
1669
+ const t = { data: translateWriteData(ctx, mm, applyCreateDefaults(mm, args.data)) };
1670
+ applyNativeOptions(CREATE_OPTIONS, args, t);
1671
+ return t;
1672
+ }, (qi, t) => qi.create(t).then(shape), {
1673
+ build: (qi, t) => qi.buildCreate(t),
1674
+ reshape: shape,
1675
+ nested: (t) => hasNestedKeys(ctx, mm, t.data),
1676
+ });
1677
+ },
1495
1678
  createMany: (args) => defer('createMany', args, () => {
1496
1679
  const data = args.data;
1497
1680
  const rows = Array.isArray(data)
@@ -1519,28 +1702,33 @@ function makeDelegate(ctx, mm, getQI, runInTx) {
1519
1702
  nested: (t) => createManyRunsOf(t).length > 1,
1520
1703
  execInTx: (table, t) => createManyByRun(table(mm.table), t, createManyRunsOf(t)),
1521
1704
  }),
1522
- update: (args) => defer('update', args, () => {
1523
- const a = requireWhere(args, 'update');
1524
- const t = {
1525
- where: translateWhere(ctx, mm, a.where),
1526
- data: translateWriteData(ctx, mm, applyUpdateTouch(mm, a.data)),
1527
- };
1528
- applyNativeOptions(UPDATE_OPTIONS, a, t);
1529
- // `optimisticLock.field` is a FIELD NAME, so it is renamed rather than
1530
- // copied: a blind passthrough would send the Prisma spelling into core
1531
- // and break on any model whose column is `@map`ped.
1532
- if (isPlainObject(a.optimisticLock)) {
1533
- t.optimisticLock = {
1534
- ...a.optimisticLock,
1535
- field: renameField(mm, String(a.optimisticLock.field)),
1705
+ update: (args) => {
1706
+ let proj = NO_WRITE_PROJECTION;
1707
+ const shape = (raw) => applyWriteProjection(proj, reshapeRow(ctx, mm, raw));
1708
+ return defer('update', args, () => {
1709
+ const a = requireWhere(args, 'update');
1710
+ proj = resolveWriteProjection(ctx, mm, 'update', a);
1711
+ const t = {
1712
+ where: translateWhere(ctx, mm, a.where),
1713
+ data: translateWriteData(ctx, mm, applyUpdateTouch(mm, a.data)),
1536
1714
  };
1537
- }
1538
- return t;
1539
- }, (qi, t) => qi.update(t).then((r) => reshapeRow(ctx, mm, r)), {
1540
- build: (qi, t) => qi.buildUpdate(t),
1541
- reshape: (raw) => reshapeRow(ctx, mm, raw),
1542
- nested: (t) => hasNestedKeys(ctx, mm, t.data),
1543
- }),
1715
+ applyNativeOptions(UPDATE_OPTIONS, a, t);
1716
+ // `optimisticLock.field` is a FIELD NAME, so it is renamed rather than
1717
+ // copied: a blind passthrough would send the Prisma spelling into core
1718
+ // and break on any model whose column is `@map`ped.
1719
+ if (isPlainObject(a.optimisticLock)) {
1720
+ t.optimisticLock = {
1721
+ ...a.optimisticLock,
1722
+ field: renameField(mm, String(a.optimisticLock.field)),
1723
+ };
1724
+ }
1725
+ return t;
1726
+ }, (qi, t) => qi.update(t).then(shape), {
1727
+ build: (qi, t) => qi.buildUpdate(t),
1728
+ reshape: shape,
1729
+ nested: (t) => hasNestedKeys(ctx, mm, t.data),
1730
+ });
1731
+ },
1544
1732
  updateMany: (args) => defer('updateMany', args, () => {
1545
1733
  const a = args;
1546
1734
  refuseRowLimit(modelName(ctx, mm), 'updateMany', a);
@@ -1553,15 +1741,20 @@ function makeDelegate(ctx, mm, getQI, runInTx) {
1553
1741
  // row, and an explicit `allowFullTableScan: false` must not be able to
1554
1742
  // turn that parity into a thrown empty-where guard.
1555
1743
  if (a.where === undefined)
1556
- t.allowFullTableScan = true;
1744
+ t.allowFullTableScan = UNSAFE;
1557
1745
  return t;
1558
1746
  }, (qi, t) => qi.updateMany(t), { build: (qi, t) => qi.buildUpdateMany(t), reshape: (raw) => raw }),
1559
- delete: (args) => defer('delete', args, () => {
1560
- const a = requireWhere(args, 'delete');
1561
- const t = { where: translateWhere(ctx, mm, a.where) };
1562
- applyNativeOptions(DELETE_OPTIONS, a, t);
1563
- return t;
1564
- }, (qi, t) => qi.delete(t).then((r) => reshapeRow(ctx, mm, r)), { build: (qi, t) => qi.buildDelete(t), reshape: (raw) => reshapeRow(ctx, mm, raw) }),
1747
+ delete: (args) => {
1748
+ let proj = NO_WRITE_PROJECTION;
1749
+ const shape = (raw) => applyWriteProjection(proj, reshapeRow(ctx, mm, raw));
1750
+ return defer('delete', args, () => {
1751
+ const a = requireWhere(args, 'delete');
1752
+ proj = resolveWriteProjection(ctx, mm, 'delete', a);
1753
+ const t = { where: translateWhere(ctx, mm, a.where) };
1754
+ applyNativeOptions(DELETE_OPTIONS, a, t);
1755
+ return t;
1756
+ }, (qi, t) => qi.delete(t).then(shape), { build: (qi, t) => qi.buildDelete(t), reshape: shape });
1757
+ },
1565
1758
  deleteMany: (args = {}) => defer('deleteMany', args, () => {
1566
1759
  const a = args;
1567
1760
  refuseRowLimit(modelName(ctx, mm), 'deleteMany', a);
@@ -1569,40 +1762,42 @@ function makeDelegate(ctx, mm, getQI, runInTx) {
1569
1762
  applyNativeOptions(DELETE_MANY_OPTIONS, a, t);
1570
1763
  // LAST, so it wins. See the same note on updateMany.
1571
1764
  if (a.where === undefined)
1572
- t.allowFullTableScan = true;
1765
+ t.allowFullTableScan = UNSAFE;
1573
1766
  return t;
1574
1767
  }, (qi, t) => qi.deleteMany(t), { build: (qi, t) => qi.buildDeleteMany(t), reshape: (raw) => raw }),
1575
- upsert: (args) => defer('upsert', args, () => {
1576
- const a = requireWhere(args, 'upsert');
1577
- const t = {
1578
- where: translateWhere(ctx, mm, a.where),
1579
- create: translateWriteData(ctx, mm, applyCreateDefaults(mm, a.create)),
1580
- update: translateWriteData(ctx, mm, applyUpdateTouch(mm, a.update)),
1581
- };
1582
- applyNativeOptions(UPSERT_OPTIONS, a, t);
1583
- return t;
1584
- }, (qi, t) => {
1585
- // Native ON CONFLICT upsert is only Prisma-equivalent when the where
1586
- // key values equal the create values AND no nested write data is
1587
- // present; otherwise emulate Prisma's lookup-first atomically.
1588
- if (upsertKeysMatch(t) && !hasNestedKeys(ctx, mm, t.create) && !hasNestedKeys(ctx, mm, t.update)) {
1589
- return qi.upsert(t).then((r) => reshapeRow(ctx, mm, r));
1590
- }
1591
- return runInTx(async (table) => {
1592
- const row = await upsertLookupFirst(table(mm.table), t);
1593
- return reshapeRow(ctx, mm, row);
1594
- });
1595
- }, {
1596
- build: (qi, t) => qi.buildUpsert(t),
1597
- reshape: (raw) => reshapeRow(ctx, mm, raw),
1598
- nested: (t) => !upsertKeysMatch(t) || hasNestedKeys(ctx, mm, t.create) || hasNestedKeys(ctx, mm, t.update),
1599
- execInTx: async (table, t) => {
1768
+ upsert: (args) => {
1769
+ let proj = NO_WRITE_PROJECTION;
1770
+ const shape = (raw) => applyWriteProjection(proj, reshapeRow(ctx, mm, raw));
1771
+ return defer('upsert', args, () => {
1772
+ const a = requireWhere(args, 'upsert');
1773
+ proj = resolveWriteProjection(ctx, mm, 'upsert', a);
1774
+ const t = {
1775
+ where: translateWhere(ctx, mm, a.where),
1776
+ create: translateWriteData(ctx, mm, applyCreateDefaults(mm, a.create)),
1777
+ update: translateWriteData(ctx, mm, applyUpdateTouch(mm, a.update)),
1778
+ };
1779
+ applyNativeOptions(UPSERT_OPTIONS, a, t);
1780
+ return t;
1781
+ }, (qi, t) => {
1782
+ // Native ON CONFLICT upsert is only Prisma-equivalent when the where
1783
+ // key values equal the create values AND no nested write data is
1784
+ // present; otherwise emulate Prisma's lookup-first atomically.
1600
1785
  if (upsertKeysMatch(t) && !hasNestedKeys(ctx, mm, t.create) && !hasNestedKeys(ctx, mm, t.update)) {
1601
- return reshapeRow(ctx, mm, await table(mm.table).upsert(t));
1786
+ return qi.upsert(t).then(shape);
1602
1787
  }
1603
- return reshapeRow(ctx, mm, await upsertLookupFirst(table(mm.table), t));
1604
- },
1605
- }),
1788
+ return runInTx(async (table) => shape(await upsertLookupFirst(table(mm.table), t)));
1789
+ }, {
1790
+ build: (qi, t) => qi.buildUpsert(t),
1791
+ reshape: shape,
1792
+ nested: (t) => !upsertKeysMatch(t) || hasNestedKeys(ctx, mm, t.create) || hasNestedKeys(ctx, mm, t.update),
1793
+ execInTx: async (table, t) => {
1794
+ if (upsertKeysMatch(t) && !hasNestedKeys(ctx, mm, t.create) && !hasNestedKeys(ctx, mm, t.update)) {
1795
+ return shape(await table(mm.table).upsert(t));
1796
+ }
1797
+ return shape(await upsertLookupFirst(table(mm.table), t));
1798
+ },
1799
+ });
1800
+ },
1606
1801
  count: (args = {}) => defer('count', args, () => {
1607
1802
  const t = {};
1608
1803
  applyNativeOptions(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` /
@@ -11,6 +11,7 @@
11
11
  import { UnsupportedFeatureError, ValidationError } from '../errors.js';
12
12
  import { snakeToCamel } from '../schema.js';
13
13
  import { isJsonPathOrderBy, isUnmatchedPlainObject, isVectorOrderBy, isWhereOperator, normalizeOrderBy, orderByEntries, } from './filters.js';
14
+ import { assertOrderDirection, resolveSkipGlobalFilters, resolveUnsafeFlag } from './types.js';
14
15
  import { isTemporalInfinity, ownLookup } from './utils.js';
15
16
  import * as whereMod from './where.js';
16
17
  /**
@@ -19,7 +20,7 @@ import * as whereMod from './where.js';
19
20
  * projection, and a value-returning aggregate is a projection by another name:
20
21
  * `groupBy({ by: ['email'] })` emits one row per distinct plaintext email, and
21
22
  * `_min`/`_max` return a stored cell verbatim. Both therefore REQUIRE the same
22
- * `includePii: true` opt-in reads use.
23
+ * `includePii` opt-in (the UNSAFE sentinel) that reads use.
23
24
  *
24
25
  * Deliberately NOT gated: `_count` (a count, never a value), `_sum` / `_avg`
25
26
  * (a computed total across many rows, not a stored cell), and `where` /
@@ -37,7 +38,8 @@ export function assertAggregatePiiOptIn(table, meta, field, column, usage, inclu
37
38
  return;
38
39
  throw new ValidationError(`[turbine] ${usage} on column "${field}" of table "${table}" is refused: that column is ` +
39
40
  'PII-tagged (`pii: true`), and this aggregate returns its stored values, which are excluded ' +
40
- 'from every default projection. Pass `includePii: true` on this call to opt in. ' +
41
+ 'from every default projection. Pass `includePii: UNSAFE` on this call to opt in ' +
42
+ "(import { UNSAFE } from 'turbine-orm'). " +
41
43
  '`_count` over a PII column (a count, not a value) and `where` / `orderBy` / `having` on PII ' +
42
44
  'columns need no opt-in.');
43
45
  }
@@ -50,7 +52,11 @@ export function buildGroupBy(qi, args) {
50
52
  }
51
53
  }
52
54
  }
53
- qi.currentSkip = args.skipGlobalFilters;
55
+ qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
56
+ // Resolve the PII opt-in ONCE, here, so the sentinel check runs on every
57
+ // groupBy (including one whose `by` names no PII column) rather than only on
58
+ // the paths that happen to consult it.
59
+ const includePii = resolveUnsafeFlag(args.includePii, 'includePii');
54
60
  const gbWhere = whereMod.mergeGlobalFilter(qi, args.where);
55
61
  const { sql: whereSql, params } = gbWhere
56
62
  ? whereMod.buildWhere(qi, gbWhere)
@@ -100,7 +106,7 @@ export function buildGroupBy(qi, args) {
100
106
  for (const entry of args.by) {
101
107
  if (typeof entry === 'string') {
102
108
  const col = qi.toColumn(entry);
103
- assertAggregatePiiOptIn(qi.table, meta, entry, col, 'groupBy `by` key', args.includePii);
109
+ assertAggregatePiiOptIn(qi.table, meta, entry, col, 'groupBy `by` key', includePii);
104
110
  claimResultKey(entry, `column "${col}"`);
105
111
  // The emitted output column is the snake_case name; claim it too (when
106
112
  // it differs from the result key) so a JSON alias like 'created_at'
@@ -115,7 +121,7 @@ export function buildGroupBy(qi, args) {
115
121
  }
116
122
  else {
117
123
  const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
118
- assertAggregatePiiOptIn(qi.table, meta, entry.field, col, 'groupBy JSON `by` key', args.includePii);
124
+ assertAggregatePiiOptIn(qi.table, meta, entry.field, col, 'groupBy JSON `by` key', includePii);
119
125
  params.push(whereMod.jsonPathParam(qi, entry.path));
120
126
  const extract = qi.dialect.buildJsonPathExtract(qi.q(col), qi.p(params.length));
121
127
  const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
@@ -183,7 +189,7 @@ export function buildGroupBy(qi, args) {
183
189
  if (target === true) {
184
190
  const col = qi.toColumn(key);
185
191
  if (aggKey === '_min' || aggKey === '_max') {
186
- assertAggregatePiiOptIn(qi.table, meta, key, col, `groupBy ${aggKey}`, args.includePii);
192
+ assertAggregatePiiOptIn(qi.table, meta, key, col, `groupBy ${aggKey}`, includePii);
187
193
  }
188
194
  // Aggregate output aliases share the same output-name namespace as
189
195
  // the group keys: `_sum: { totalPrice: true, total_price: {json} }`
@@ -197,7 +203,7 @@ export function buildGroupBy(qi, args) {
197
203
  }
198
204
  const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
199
205
  if (aggKey === '_min' || aggKey === '_max') {
200
- assertAggregatePiiOptIn(qi.table, meta, target.field, col, `groupBy ${aggKey} JSON target`, args.includePii);
206
+ assertAggregatePiiOptIn(qi.table, meta, target.field, col, `groupBy ${aggKey} JSON target`, includePii);
201
207
  }
202
208
  const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
203
209
  if (alwaysNumeric && target.type === 'text') {
@@ -369,6 +375,9 @@ export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
369
375
  throw new ValidationError(`[turbine] Cannot order groupBy by "_count" on table "${qi.table}": _count is not selected. ` +
370
376
  `Orderable keys: ${validKeys()}.`);
371
377
  }
378
+ // Refuse a direction that is neither asc nor desc BEFORE normalizeOrderBy,
379
+ // whose `=== 'desc' ? DESC : ASC` would silently sort ascending.
380
+ assertOrderDirection(value, `groupBy orderBy "_count" on table "${qi.table}"`);
372
381
  const { dir, nulls } = normalizeOrderBy(value);
373
382
  parts.push(`${expr} ${dir}${qi.nullsSuffix(nulls)}`);
374
383
  continue;
@@ -386,6 +395,7 @@ export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
386
395
  throw new ValidationError(`[turbine] Cannot order groupBy by "${key}.${field}" on table "${qi.table}": ` +
387
396
  `that aggregate is not requested in this call. Orderable keys: ${validKeys()}.`);
388
397
  }
398
+ assertOrderDirection(dirSpec, `groupBy orderBy "${key}.${field}" on table "${qi.table}"`);
389
399
  const { dir, nulls } = normalizeOrderBy(dirSpec);
390
400
  parts.push(`${expr} ${dir}${qi.nullsSuffix(nulls)}`);
391
401
  }
@@ -397,6 +407,7 @@ export function buildGroupByOrderBy(qi, orderBy, byOrderExprs, aggOrderExprs) {
397
407
  throw new ValidationError(`[turbine] Unknown field "${key}" in groupBy orderBy on table "${qi.table}". ` +
398
408
  `Orderable keys: ${validKeys()}.`);
399
409
  }
410
+ assertOrderDirection(value, `groupBy orderBy "${key}" on table "${qi.table}"`);
400
411
  const { dir, nulls } = normalizeOrderBy(value);
401
412
  parts.push(`${expr} ${dir}${qi.nullsSuffix(nulls)}`);
402
413
  }
@@ -465,6 +476,7 @@ export function buildDistinctOnSource(qi, distinctOn, whereSql, params) {
465
476
  'sort specs, and JSON-path orderings only.');
466
477
  }
467
478
  const col = qi.resolveOrderByColumn(qi.table, qi.tableMeta, key);
479
+ assertOrderDirection(value, `groupBy distinctOn.orderBy "${key}" on table "${qi.table}"`);
468
480
  const { dir, nulls } = normalizeOrderBy(value);
469
481
  orderParts.push(`${qi.q(col)} ${dir}${qi.nullsSuffix(nulls)}`);
470
482
  }
@@ -760,7 +772,9 @@ function temporalAggValue(qi, col, value) {
760
772
  return value === '-infinity' ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY;
761
773
  }
762
774
  export function buildAggregate(qi, args) {
763
- qi.currentSkip = args.skipGlobalFilters;
775
+ qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
776
+ // Resolved once, up front: see buildGroupBy.
777
+ const includePii = resolveUnsafeFlag(args.includePii, 'includePii');
764
778
  const aggWhere = whereMod.mergeGlobalFilter(qi, args.where);
765
779
  const { sql: whereSql, params } = aggWhere
766
780
  ? whereMod.buildWhere(qi, aggWhere)
@@ -831,7 +845,7 @@ export function buildAggregate(qi, args) {
831
845
  for (const [field, enabled] of Object.entries(args._min)) {
832
846
  if (enabled) {
833
847
  const col = qi.toColumn(field);
834
- assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _min', args.includePii);
848
+ assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _min', includePii);
835
849
  selectExprs.push(`MIN(${qi.q(col)}) AS ${qi.q(`_min_${col}`)}`);
836
850
  }
837
851
  }
@@ -841,7 +855,7 @@ export function buildAggregate(qi, args) {
841
855
  for (const [field, enabled] of Object.entries(args._max)) {
842
856
  if (enabled) {
843
857
  const col = qi.toColumn(field);
844
- assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _max', args.includePii);
858
+ assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _max', includePii);
845
859
  selectExprs.push(`MAX(${qi.q(col)}) AS ${qi.q(`_max_${col}`)}`);
846
860
  }
847
861
  }
@@ -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;
@@ -77,7 +77,10 @@ const MAX_DEPTH = 10;
77
77
  * keep the `select: undefined, omit: undefined` fast path and the emitted SQL
78
78
  * stays byte-identical.
79
79
  */
80
- export function defaultProjectionFields(meta, includePii) {
80
+ export function defaultProjectionFields(meta,
81
+ // Either the already-resolved boolean (top-level callers) or the raw sentinel
82
+ // (the relation-load context); both are only ever tested for truthiness.
83
+ includePii) {
81
84
  if (includePii)
82
85
  return undefined;
83
86
  const hidden = new Set();