turbine-orm 0.77.1 → 0.78.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 (93) hide show
  1. package/README.md +13 -9
  2. package/dist/cjs/cli/config.d.ts +7 -1
  3. package/dist/cjs/cli/config.js +11 -2
  4. package/dist/cjs/cli/destructive.d.ts +1 -1
  5. package/dist/cjs/cli/destructive.js +307 -9
  6. package/dist/cjs/cli/index.js +252 -42
  7. package/dist/cjs/cli/mcp.d.ts +23 -0
  8. package/dist/cjs/cli/mcp.js +190 -152
  9. package/dist/cjs/cli/migrate.d.ts +243 -3
  10. package/dist/cjs/cli/migrate.js +432 -43
  11. package/dist/cjs/cli/sql-statements.js +27 -0
  12. package/dist/cjs/cli/studio.d.ts +0 -1
  13. package/dist/cjs/cli/studio.js +9 -7
  14. package/dist/cjs/client.d.ts +8 -1
  15. package/dist/cjs/client.js +7 -0
  16. package/dist/cjs/connection-url.d.ts +82 -0
  17. package/dist/cjs/connection-url.js +187 -1
  18. package/dist/cjs/errors.d.ts +112 -12
  19. package/dist/cjs/errors.js +558 -114
  20. package/dist/cjs/generate.js +47 -15
  21. package/dist/cjs/index.d.ts +1 -1
  22. package/dist/cjs/introspect.d.ts +33 -0
  23. package/dist/cjs/introspect.js +54 -1
  24. package/dist/cjs/mssql.js +21 -1
  25. package/dist/cjs/nested-write.js +85 -14
  26. package/dist/cjs/pipeline-submittable.d.ts +2 -0
  27. package/dist/cjs/pipeline-submittable.js +88 -3
  28. package/dist/cjs/pipeline.js +13 -1
  29. package/dist/cjs/powdb-introspect.d.ts +5 -1
  30. package/dist/cjs/powdb-introspect.js +5 -1
  31. package/dist/cjs/powql.d.ts +14 -0
  32. package/dist/cjs/powql.js +44 -4
  33. package/dist/cjs/prisma-compat.js +95 -8
  34. package/dist/cjs/query/aggregates.js +56 -6
  35. package/dist/cjs/query/builder.d.ts +76 -13
  36. package/dist/cjs/query/builder.js +188 -58
  37. package/dist/cjs/query/compound-unique.d.ts +76 -0
  38. package/dist/cjs/query/compound-unique.js +129 -0
  39. package/dist/cjs/query/index.d.ts +1 -1
  40. package/dist/cjs/query/types.d.ts +65 -11
  41. package/dist/cjs/query/where.d.ts +85 -19
  42. package/dist/cjs/query/where.js +262 -47
  43. package/dist/cjs/query/writes.d.ts +11 -2
  44. package/dist/cjs/query/writes.js +116 -21
  45. package/dist/cjs/seed.d.ts +16 -0
  46. package/dist/cjs/seed.js +16 -0
  47. package/dist/cli/config.d.ts +7 -1
  48. package/dist/cli/config.js +11 -2
  49. package/dist/cli/destructive.d.ts +1 -1
  50. package/dist/cli/destructive.js +307 -9
  51. package/dist/cli/index.js +254 -44
  52. package/dist/cli/mcp.d.ts +23 -0
  53. package/dist/cli/mcp.js +187 -150
  54. package/dist/cli/migrate.d.ts +243 -3
  55. package/dist/cli/migrate.js +423 -45
  56. package/dist/cli/sql-statements.js +27 -0
  57. package/dist/cli/studio.d.ts +0 -1
  58. package/dist/cli/studio.js +10 -7
  59. package/dist/client.d.ts +8 -1
  60. package/dist/client.js +7 -0
  61. package/dist/connection-url.d.ts +82 -0
  62. package/dist/connection-url.js +183 -0
  63. package/dist/errors.d.ts +112 -12
  64. package/dist/errors.js +558 -114
  65. package/dist/generate.js +47 -15
  66. package/dist/index.d.ts +1 -1
  67. package/dist/introspect.d.ts +33 -0
  68. package/dist/introspect.js +53 -1
  69. package/dist/mssql.js +21 -1
  70. package/dist/nested-write.js +85 -14
  71. package/dist/pipeline-submittable.d.ts +2 -0
  72. package/dist/pipeline-submittable.js +87 -3
  73. package/dist/pipeline.js +14 -2
  74. package/dist/powdb-introspect.d.ts +5 -1
  75. package/dist/powdb-introspect.js +5 -1
  76. package/dist/powql.d.ts +14 -0
  77. package/dist/powql.js +45 -5
  78. package/dist/prisma-compat.js +96 -9
  79. package/dist/query/aggregates.js +56 -6
  80. package/dist/query/builder.d.ts +76 -13
  81. package/dist/query/builder.js +188 -58
  82. package/dist/query/compound-unique.d.ts +76 -0
  83. package/dist/query/compound-unique.js +126 -1
  84. package/dist/query/index.d.ts +1 -1
  85. package/dist/query/types.d.ts +65 -11
  86. package/dist/query/where.d.ts +85 -19
  87. package/dist/query/where.js +260 -47
  88. package/dist/query/writes.d.ts +11 -2
  89. package/dist/query/writes.js +117 -22
  90. package/dist/seed.d.ts +16 -0
  91. package/dist/seed.js +16 -0
  92. package/package.json +3 -3
  93. package/skills/turbine-orm/SKILL.md +37 -10
package/dist/powql.d.ts CHANGED
@@ -218,6 +218,20 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
218
218
  private assertNoForceCustomPlan;
219
219
  /** See query/compound-unique.ts: one rule and one message across engines. */
220
220
  private assertIdentifiesOneRow;
221
+ /**
222
+ * The single-row WRITE rule (`update` / `delete` return one row, so their
223
+ * `where` must identify one; `upsert`'s `where` names the row it conflicts
224
+ * on), shared with the SQL engines through query/compound-unique.ts so the
225
+ * two cannot disagree about which writes are valid. Skipped under the
226
+ * explicit full-table opt-in, which already means "every row". For `update` /
227
+ * `delete` it runs AFTER `assertCompiledWhere`, so an empty selector keeps
228
+ * the empty-where message and this only refuses a non-empty one that names no
229
+ * key; `upsert` takes no `allowFullTableScan` and has no empty-where guard to
230
+ * run first, so this is its only where check and it covers `{}` as well.
231
+ */
232
+ private assertMutationIdentifiesOneRow;
233
+ /** A caller `where` with a Prisma compound-unique selector expanded to its member columns. */
234
+ private expandedWhere;
221
235
  private assertPagination;
222
236
  /** A predicate that is always false, the empty-`in` / contradiction sentinel. */
223
237
  private alwaysFalse;
package/dist/powql.js CHANGED
@@ -39,7 +39,7 @@ import { NotFoundError, OptimisticLockError, ReadOnlyError, TimeoutError, Unsupp
39
39
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
40
40
  import { ALL_POWDB_CAPABILITIES, baseTsType, coerceNativeValue, isJsonColumn, isPowdbDatetimeColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlDotted, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb-shared.js';
41
41
  import { assertAggregatePiiOptIn } from './query/aggregates.js';
42
- import { assertWhereIdentifiesOneRow, expandCompoundUniqueWhere } from './query/compound-unique.js';
42
+ import { assertMutationWhereIdentifiesOneRow, assertWhereIdentifiesOneRow, expandCompoundUniqueWhere, } from './query/compound-unique.js';
43
43
  import { ARRAY_OPERATOR_KEYS, isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/filters.js';
44
44
  import { warnUnknownQueryOptions } from './query/option-surface.js';
45
45
  import { applyStableRelationOrderTo, normalizeWithClause } from './query/relation-names.js';
@@ -446,6 +446,28 @@ export class PowqlInterface {
446
446
  assertIdentifiesOneRow(where) {
447
447
  assertWhereIdentifiesOneRow(this.meta, this.table, where);
448
448
  }
449
+ /**
450
+ * The single-row WRITE rule (`update` / `delete` return one row, so their
451
+ * `where` must identify one; `upsert`'s `where` names the row it conflicts
452
+ * on), shared with the SQL engines through query/compound-unique.ts so the
453
+ * two cannot disagree about which writes are valid. Skipped under the
454
+ * explicit full-table opt-in, which already means "every row". For `update` /
455
+ * `delete` it runs AFTER `assertCompiledWhere`, so an empty selector keeps
456
+ * the empty-where message and this only refuses a non-empty one that names no
457
+ * key; `upsert` takes no `allowFullTableScan` and has no empty-where guard to
458
+ * run first, so this is its only where check and it covers `{}` as well.
459
+ */
460
+ assertMutationIdentifiesOneRow(where, allowFullTableScan, operation) {
461
+ if (allowFullTableScan)
462
+ return;
463
+ assertMutationWhereIdentifiesOneRow(this.meta, this.table, where, operation);
464
+ }
465
+ /** A caller `where` with a Prisma compound-unique selector expanded to its member columns. */
466
+ expandedWhere(where) {
467
+ if (!where)
468
+ return where;
469
+ return expandCompoundUniqueWhere(this.meta, where);
470
+ }
449
471
  assertPagination(limit, offset, context) {
450
472
  for (const [name, value] of [
451
473
  ['limit', limit],
@@ -2665,14 +2687,22 @@ export class PowqlInterface {
2665
2687
  async update(args) {
2666
2688
  return this.withMiddleware('update', args, async () => {
2667
2689
  if (hasRelationFields(args.data, this.meta)) {
2690
+ // The nested engine re-enters `update` / `findUnique` for the parent
2691
+ // row before it writes anything, so the identity rule below still runs
2692
+ // first on this path.
2668
2693
  return this.nestedUpdate(args);
2669
2694
  }
2695
+ const allowFullTableScan = resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan');
2696
+ // Prisma compound-unique selector → column conjunction, so the selector
2697
+ // counts as the key it is (engine parity with the SQL buildUpdate).
2698
+ const userWhere = this.expandedWhere(args.where);
2670
2699
  const params = [];
2671
- const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2700
+ const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout);
2672
2701
  let where = this.buildWhere(resolvedWhere, params);
2673
2702
  // `false` here refused an empty where even WITH the opt-in, while every
2674
2703
  // SQL engine accepted it: verified by probe on buildUpdate/buildDelete.
2675
- this.assertCompiledWhere(where, resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'), 'update');
2704
+ this.assertCompiledWhere(where, allowFullTableScan, 'update');
2705
+ this.assertMutationIdentifiesOneRow(userWhere, allowFullTableScan, 'update');
2676
2706
  where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2677
2707
  let setClause = this.buildUpdateAssignments(args.data, params);
2678
2708
  // Optimistic locking, matching the SQL engines exactly: bump the version
@@ -2846,10 +2876,13 @@ export class PowqlInterface {
2846
2876
  }
2847
2877
  async delete(args) {
2848
2878
  return this.withMiddleware('delete', args, async () => {
2879
+ const allowFullTableScan = resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan');
2880
+ const userWhere = this.expandedWhere(args.where);
2849
2881
  const params = [];
2850
- const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2882
+ const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout);
2851
2883
  let where = this.buildWhere(resolvedWhere, params);
2852
- this.assertCompiledWhere(where, resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'), 'delete');
2884
+ this.assertCompiledWhere(where, allowFullTableScan, 'delete');
2885
+ this.assertMutationIdentifiesOneRow(userWhere, allowFullTableScan, 'delete');
2853
2886
  where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2854
2887
  // `returning` hands back the deleted row(s), no separate pre-image reselect needed.
2855
2888
  const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
@@ -2873,6 +2906,13 @@ export class PowqlInterface {
2873
2906
  }
2874
2907
  async upsert(args) {
2875
2908
  return this.withMiddleware('upsert', args, async () => {
2909
+ // The single-row rule for `upsert`, shared with the SQL engines through
2910
+ // query/compound-unique.ts. PowqlInterface is a PARALLEL implementation,
2911
+ // not a subclass, so a rule adopted only on the SQL side is two engines
2912
+ // disagreeing about whether a query is VALID, which is the failure class
2913
+ // 0.64.0 and 0.72.0 were both spent on. Before `applyPkDefault`, so a
2914
+ // refused upsert mints no client-side UUID and sends nothing.
2915
+ this.assertMutationIdentifiesOneRow(this.expandedWhere(args.where), false, 'upsert');
2876
2916
  const createData = this.applyPkDefault(args.create);
2877
2917
  const pkCol = this.meta.primaryKey[0];
2878
2918
  if (this.meta.primaryKey.length !== 1 || !pkCol) {
@@ -123,7 +123,7 @@ import { TurbineError, TurbineErrorCode, UnsupportedFeatureError, ValidationErro
123
123
  import { createManyShapeRuns } from './nested-write.js';
124
124
  import { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
125
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';
126
- import { suggestKey } from './query/utils.js';
126
+ import { markInternalCombinator, suggestKey } from './query/utils.js';
127
127
  import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
128
128
  import { assertWhereDepth, MAX_WHERE_DEPTH } from './query/where-compile.js';
129
129
  // ---------------------------------------------------------------------------
@@ -260,6 +260,33 @@ function lookupsFor(ctx, mm) {
260
260
  return l;
261
261
  }
262
262
  /** Resolve a turbine relation's target Prisma model map (for nested translation). */
263
+ /**
264
+ * A `where` fragment no row satisfies, for Prisma's empty `OR`.
265
+ *
266
+ * `{ <key>: { in: [] } }`, which core compiles to `= ANY($n)` with an empty
267
+ * array: false for every row, NULL-valued ones included, and one ordinary bound
268
+ * param rather than a literal spliced into the SQL. The column is the first
269
+ * primary-key column, else the table's first column, so it is always a real
270
+ * column of this table and the predicate stays a plain scalar comparison the
271
+ * cache fingerprints like any other.
272
+ *
273
+ * A table this layer cannot name a column of THROWS. The fallback used to be
274
+ * `{}`, an empty `where` fragment - which is not "no rows" but its exact
275
+ * opposite, EVERY row, and it would have been merged in silently beside the
276
+ * caller's other predicates. A sentinel builder that cannot build a sentinel
277
+ * has not succeeded, and the one shape it exists to compile is the one where
278
+ * answering "all of them" instead of "none of them" is worst.
279
+ */
280
+ function matchNothing(ctx, mm) {
281
+ const meta = ctx.schema.tables[mm.table];
282
+ const column = meta?.primaryKey?.[0] ?? meta?.allColumns?.[0];
283
+ if (!meta || !column) {
284
+ throw new ValidationError(`An empty \`OR\` on model ${mm.table} means "no rows", and compiling that needs one real column ` +
285
+ `of the table, but the schema metadata for "${mm.table}" lists none. Regenerate the client ` +
286
+ '(`npx turbine generate`) so the table carries its columns, or drop the empty `OR`.');
287
+ }
288
+ return { [meta.reverseColumnMap?.[column] ?? column]: { in: [] } };
289
+ }
263
290
  function relTargetModel(ctx, mm, turbineRel) {
264
291
  const rd = ctx.schema.tables[mm.table]?.relations?.[turbineRel];
265
292
  if (!rd)
@@ -600,8 +627,30 @@ function translateWhere(ctx, mm, where, depth = 0) {
600
627
  if (!isPlainObject(where))
601
628
  return where;
602
629
  const out = {};
630
+ // The empty-`OR` sentinel, held aside rather than merged into `out`. At most
631
+ // one per level, since `OR` is a single object key.
632
+ let matchNone;
603
633
  for (const [key, val] of Object.entries(where)) {
604
634
  if (COMBINATORS.has(key)) {
635
+ // An EMPTY `OR` is false in Prisma (no branch can match), while an empty
636
+ // `AND` / `NOT` is true. Core drops an empty combinator entirely, so the
637
+ // same call returned EVERY row here: the classic trigger is
638
+ // `OR: ids.map(...)` over a selection that turned out to be empty, i.e.
639
+ // "none of these" answered with "all of them". Compiled to a predicate
640
+ // that matches nothing so this layer keeps Prisma's meaning; core's own
641
+ // semantics are unchanged and documented separately.
642
+ //
643
+ // Held aside, NOT `Object.assign`ed onto `out`: the sentinel is keyed on
644
+ // a real column of the table, so merging it made two predicates on one
645
+ // key collide and JavaScript's key ORDER decided which survived.
646
+ // `{ OR: [], id: 5 }` kept the caller's `id = 5` and dropped the
647
+ // sentinel (returning the row Prisma excludes); `{ id: 5, OR: [] }` kept
648
+ // the sentinel and dropped the caller's `id = 5`. Same query, same
649
+ // meaning, opposite predicate.
650
+ if (key === 'OR' && Array.isArray(val) && val.length === 0) {
651
+ matchNone = matchNothing(ctx, mm);
652
+ continue;
653
+ }
605
654
  // An `AND` / `OR` array of N conditions is ONE level, not N: the elements
606
655
  // are siblings, so they all translate at the same incremented depth.
607
656
  out[key] = Array.isArray(val)
@@ -623,6 +672,17 @@ function translateWhere(ctx, mm, where, depth = 0) {
623
672
  // Relation filter.
624
673
  const rel = mm.relations[key];
625
674
  if (rel) {
675
+ // Prisma spells "this to-one relation has no related row" as a bare
676
+ // `null` on the relation key; core spells it `{ is: null }` and answers a
677
+ // bare null with E003. The generic message then degraded into naming the
678
+ // key that was just passed ("Did you mean "user" (a relation)?"), because
679
+ // the adapter had already renamed it. To-many keys are NOT mapped: Prisma
680
+ // has no bare-null shape there, so core still refuses them by name rather
681
+ // than this layer inventing a meaning.
682
+ if (val === null && rel.cardinality === 'one') {
683
+ out[rel.name] = { is: null };
684
+ continue;
685
+ }
626
686
  const target = relTargetModel(ctx, mm, rel.name);
627
687
  out[rel.name] = translateRelationFilter(ctx, target, val, depth + 1);
628
688
  continue;
@@ -631,7 +691,20 @@ function translateWhere(ctx, mm, where, depth = 0) {
631
691
  // through unchanged (Prisma operator names match Turbine's).
632
692
  out[renameField(mm, key)] = val;
633
693
  }
634
- return out;
694
+ if (!matchNone)
695
+ return out;
696
+ // Nothing else at this level: the sentinel IS the where, no wrapper needed.
697
+ if (Object.keys(out).length === 0)
698
+ return matchNone;
699
+ // Otherwise conjoin, so neither half can overwrite the other whatever order
700
+ // the caller wrote the keys in. BRANDED, because an `AND` array is the shape
701
+ // that makes a statement unnamed (a caller-written combinator has a
702
+ // caller-sized arity); this one is Turbine's, with a fixed arity of two, and
703
+ // an unbranded wrapper here would quietly take every compat query carrying an
704
+ // empty `OR` off named prepared statements. The caller's own combinators sit
705
+ // INSIDE `out` and are walked at the next level with their own brand check,
706
+ // so they still count.
707
+ return markInternalCombinator({ AND: [out, matchNone] });
635
708
  }
636
709
  function translateRelationFilter(ctx, target, val, depth) {
637
710
  if (!isPlainObject(val))
@@ -1161,15 +1234,29 @@ function translateCursor(ctx, mm, prismaArgs, t) {
1161
1234
  const op = desc ? 'lte' : 'gte';
1162
1235
  t.where = mergeKeyset(t.where ?? {}, field, op, value);
1163
1236
  }
1164
- /** Flatten a Turbine orderBy (object or single-object array) into [field, isDesc] pairs. */
1237
+ /**
1238
+ * Flatten a Turbine orderBy (object, or an array of them) into [field, isDesc]
1239
+ * pairs.
1240
+ *
1241
+ * EVERY array element is read. Reading only an array of length one returned
1242
+ * `[]` for a two-element array, and `[]` is also what "no orderBy at all"
1243
+ * returns, so {@link translateCursor} took its no-orderBy branch: it found a
1244
+ * bare cursor on the primary key, wrote `orderBy = { id: 'asc' }` over the
1245
+ * caller's array, and returned the rows following the anchor in PK order
1246
+ * instead of in the caller's order. The single-sort-key check downstream is
1247
+ * what refuses a shape a bare (inclusive) cursor cannot express, and it can
1248
+ * only fire if the pairs it counts are all there.
1249
+ */
1165
1250
  function orderByPairs(ob) {
1166
- const one = Array.isArray(ob) ? (ob.length === 1 ? ob[0] : undefined) : ob;
1167
- if (!isPlainObject(one))
1168
- return [];
1251
+ const entries = Array.isArray(ob) ? ob : [ob];
1169
1252
  const out = [];
1170
- for (const [k, v] of Object.entries(one)) {
1171
- const dir = isPlainObject(v) ? v.sort : v;
1172
- out.push([k, dir === 'desc']);
1253
+ for (const entry of entries) {
1254
+ if (!isPlainObject(entry))
1255
+ continue;
1256
+ for (const [k, v] of Object.entries(entry)) {
1257
+ const dir = isPlainObject(v) ? v.sort : v;
1258
+ out.push([k, dir === 'desc']);
1259
+ }
1173
1260
  }
1174
1261
  return out;
1175
1262
  }
@@ -211,7 +211,7 @@ export function buildGroupBy(qi, args) {
211
211
  // would emit two "_sum_total_price" columns and silently drop one.
212
212
  claimResultKey(`${aggKey}_${col}`, `${aggKey} of column "${col}"`);
213
213
  const inner = `${sqlFn}(${qi.q(col)})`;
214
- const expr = aggKey === '_avg' ? qi.castAgg(inner, 'float') : inner;
214
+ const expr = aggKey === '_avg' ? plainAvgExpr(qi, col) : inner;
215
215
  selectExprs.push(`${expr} AS ${qi.q(`${aggKey}_${col}`)}`);
216
216
  // Canonical field, matching the result bucket the transform fills;
217
217
  // `orderBy` may spell it either way (see {@link lookupGroupKey}).
@@ -322,13 +322,17 @@ export function buildGroupBy(qi, args) {
322
322
  // aggregates keep the snake→camel field mapping.
323
323
  const jsonAgg = (rawKey) => jsonAggFields.get(rawKey);
324
324
  const fieldFor = (rawKey, col) => jsonAgg(rawKey)?.field ?? qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
325
+ // A JSON-path `_sum` / `_avg` casts its extracted text to numeric in
326
+ // SQL and is always a number; a plain column follows its source type
327
+ // (see {@link isExactNumericType}).
328
+ const sumAvg = (rawKey, rawValue) => jsonAgg(rawKey) ? (rawValue !== null ? Number(rawValue) : null) : sumAvgValue(qi, rawKey.slice(5), rawValue);
325
329
  for (const [rawKey, rawValue] of Object.entries(row)) {
326
330
  if (rawKey.startsWith('_sum_')) {
327
- sumObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
331
+ sumObj[fieldFor(rawKey, rawKey.slice(5))] = sumAvg(rawKey, rawValue);
328
332
  hasSums = true;
329
333
  }
330
334
  else if (rawKey.startsWith('_avg_')) {
331
- avgObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
335
+ avgObj[fieldFor(rawKey, rawKey.slice(5))] = sumAvg(rawKey, rawValue);
332
336
  hasAvgs = true;
333
337
  }
334
338
  else if (rawKey.startsWith('_min_')) {
@@ -883,6 +887,52 @@ function temporalAggValue(qi, col, value) {
883
887
  return qi.utcTimestamps !== false ? parseDbDate(value) : new Date(value);
884
888
  return value;
885
889
  }
890
+ /**
891
+ * Whether `SUM` / `AVG` over a column of `pgType` is EXACT on the wire and must
892
+ * not be narrowed to a JS number.
893
+ *
894
+ * PostgreSQL widens both past their input: over int8 they are `numeric`, over
895
+ * numeric they stay `numeric`. (An int2 / int4 sum is int8 and its average
896
+ * `numeric` too, but those totals fit a double, which is the line this draws.)
897
+ * The driver delivers `numeric` as its exact text, because no parser is
898
+ * registered for it, deliberately: the type is arbitrary-precision. `Number()`
899
+ * over that text rounded a `SUM(int8)` of 461168601842738790350 to
900
+ * 461168601842738800000 and a numeric(12,2) total of 1020.50 to 1020.5, while
901
+ * `_min` / `_max` on the very same columns came back exact, because they hand
902
+ * the driver's value through untouched.
903
+ *
904
+ * So for these source types the aggregate is returned as the driver delivered
905
+ * it (on PostgreSQL the text, a string; an engine whose driver already hands
906
+ * back a number keeps that number) and `_avg` is not cast to float in SQL,
907
+ * which would otherwise round on the server before the value reached the
908
+ * wire. Every other type keeps `Number()` and the cast. Spelled for every
909
+ * engine's type names: the PostgreSQL `udt_name` (`int8`, `numeric`) and the
910
+ * SQL names the other dialects report (`bigint`, `decimal`), with case and any
911
+ * `(precision, scale)` suffix ignored. JSON-path aggregates never reach this:
912
+ * they cast the extracted text to numeric themselves and stay numbers.
913
+ */
914
+ function isExactNumericType(pgType) {
915
+ const paren = pgType.indexOf('(');
916
+ const base = (paren === -1 ? pgType : pgType.slice(0, paren)).trim().toLowerCase();
917
+ return base === 'int8' || base === 'bigint' || base === 'numeric' || base === 'decimal';
918
+ }
919
+ /** `AVG(col)`, float-cast unless the source column is exact (see {@link isExactNumericType}). */
920
+ function plainAvgExpr(qi, col) {
921
+ const inner = `AVG(${qi.q(col)})`;
922
+ return isExactNumericType(whereMod.getColumnPgType(qi, col)) ? inner : qi.castAgg(inner, 'float');
923
+ }
924
+ /**
925
+ * A `_sum` / `_avg` value over a plain column: the driver's value verbatim
926
+ * for an exact source type (see {@link isExactNumericType}), a JS number for
927
+ * every other, `null` for an aggregate over zero rows.
928
+ */
929
+ function sumAvgValue(qi, col, value) {
930
+ if (value === null || value === undefined)
931
+ return null;
932
+ if (isExactNumericType(whereMod.getColumnPgType(qi, col)))
933
+ return value;
934
+ return Number(value);
935
+ }
886
936
  export function buildAggregate(qi, args) {
887
937
  qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
888
938
  // Resolved once, up front: see buildGroupBy.
@@ -944,7 +994,7 @@ export function buildAggregate(qi, args) {
944
994
  for (const [field, enabled] of Object.entries(args._avg)) {
945
995
  if (enabled) {
946
996
  const col = qi.toColumn(field);
947
- selectExprs.push(`${qi.castAgg(`AVG(${qi.q(col)})`, 'float')} AS ${qi.q(`_avg_${col}`)}`);
997
+ selectExprs.push(`${plainAvgExpr(qi, col)} AS ${qi.q(`_avg_${col}`)}`);
948
998
  }
949
999
  }
950
1000
  }
@@ -1014,13 +1064,13 @@ export function buildAggregate(qi, args) {
1014
1064
  if (key.startsWith('_sum_')) {
1015
1065
  const col = key.slice(5);
1016
1066
  const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1017
- sumObj[field] = val !== null ? Number(val) : null;
1067
+ sumObj[field] = sumAvgValue(qi, col, val);
1018
1068
  hasSums = true;
1019
1069
  }
1020
1070
  else if (key.startsWith('_avg_')) {
1021
1071
  const col = key.slice(5);
1022
1072
  const field = qi.tableMeta.reverseColumnMap[col] ?? snakeToCamel(col);
1023
- avgObj[field] = val !== null ? Number(val) : null;
1073
+ avgObj[field] = sumAvgValue(qi, col, val);
1024
1074
  hasAvgs = true;
1025
1075
  }
1026
1076
  else if (key.startsWith('_min_')) {
@@ -589,32 +589,80 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
589
589
  private pkOrderBy;
590
590
  /**
591
591
  * The field names a `cursor` actually seeks on (its own keys with a defined
592
- * value), in the canonical sorted order the cursor conditions are built in.
593
- * Empty for a missing cursor or one whose every value is `undefined` (which
594
- * emits no seek condition at all, so it does not paginate).
592
+ * value), in canonical sorted order. This is the diagnostic / implicit-
593
+ * ordering view; the seek itself orders the fields by their `orderBy`
594
+ * precedence (see {@link cursorSeekEntries}). Empty for a missing cursor or
595
+ * one whose every value is `undefined` (which emits no seek condition at
596
+ * all, so it does not paginate).
595
597
  */
596
598
  private cursorFields;
597
599
  /**
598
600
  * The ascending ordering implied by a `cursor`, or `undefined` when the shape
599
601
  * is too ambiguous to order safely.
600
602
  *
601
- * A cursor seek emits `col > $n` per field (`<` when the orderBy says desc),
602
- * so the ONLY ordering coherent with it is on the cursor's own field: ordering
603
- * a seek on column X by column Y walks the table in an order the seek does not
604
- * follow, which skips and repeats rows just as badly as no order at all. That
605
- * is why this orders on the cursor field rather than blindly on the primary
606
- * key when the two differ.
603
+ * A cursor seek compares against the cursor's own field(s) (`col > $n`, `<`
604
+ * when the orderBy says desc), so the ONLY ordering coherent with it is on
605
+ * those fields: ordering a seek on column X by column Y walks the table in an
606
+ * order the seek does not follow, which skips and repeats rows just as badly
607
+ * as no order at all. That is why this orders on the cursor field rather than
608
+ * blindly on the primary key when the two differ.
607
609
  *
608
610
  * Returns `undefined` (warn, inject nothing) for two shapes:
609
- * - a MULTI-field cursor. `a > $1 AND b > $2` is a conjunction, not a proper
610
- * composite keyset seek (`(a, b) > ($1, $2)`), so no single ORDER BY makes
611
- * it correct. Injecting `(a asc, b asc)` would dress a broken seek up as a
612
- * sound one.
611
+ * - a MULTI-field cursor. The seek is a proper keyset predicate, but its
612
+ * column PRECEDENCE comes from the `orderBy` ({@link cursorSeekEntries}),
613
+ * and with no orderBy there is nothing to derive it from: the cursor
614
+ * object's key order is canonicalized away, so `(a asc, b asc)` would be a
615
+ * guess at which key the caller meant to lead. The caller must state the
616
+ * order, and the unordered-page warning says so.
613
617
  * - a field that does not resolve to a real column. Column validation belongs
614
618
  * to the normal build path, which raises a precise error; synthesizing an
615
619
  * ORDER BY on it here would only change which error the caller sees.
616
620
  */
617
621
  private cursorOrderBy;
622
+ /**
623
+ * The fields a `cursor` seeks on, in KEYSET order with their seek direction.
624
+ * THE single authority the cache fingerprint (`cur=`), the SQL build and the
625
+ * cache-hit param collect all consume, so the three cannot disagree about
626
+ * which value binds to which `$n`.
627
+ *
628
+ * Keyset order is the `orderBy` precedence: the predicate for
629
+ * `orderBy: [{ viewCount }, { id }]` must test `view_count` first and `id`
630
+ * only within equal `view_count`, whatever order the caller wrote the cursor
631
+ * object in (its keys are canonicalized for the cache anyway). A cursor field
632
+ * the orderBy does not name trails the named ones in sorted key order and
633
+ * seeks ascending, the same default a single-field cursor with no orderBy has
634
+ * always had.
635
+ *
636
+ * Directions are indexed by the RESOLVED COLUMN, never the caller's key: both
637
+ * `cursor` and `orderBy` take either spelling, so a cursor written
638
+ * `{ created_at }` against `orderBy: { createdAt: 'desc' }` used to miss the
639
+ * lookup, default to ascending, and seek the wrong side of the page. The
640
+ * `{ sort, nulls }` spec form is normalized for the same reason. A relation /
641
+ * JSON-path / vector orderBy key resolves to no column and is skipped.
642
+ *
643
+ * An unknown cursor field throws the same E003 here that the build path
644
+ * threw before this helper existed; the fingerprint simply meets it first.
645
+ */
646
+ private cursorSeekEntries;
647
+ /**
648
+ * The keyset predicate for cursor terms in keyset order.
649
+ *
650
+ * One field is the plain `col > $1` (byte-identical to every single-field
651
+ * cursor ever emitted). Two or more expand to
652
+ * `(c1 > $1 OR (c1 = $1 AND c2 > $2) OR (c1 = $1 AND c2 = $2 AND c3 > $3))`,
653
+ * with `<` for a `desc` field: the row-value form `(c1, c2) > ($1, $2)` is
654
+ * shorter, but it cannot express a MIXED direction at all and SQL Server has
655
+ * no row-value comparison, so the expanded form is the one path every engine
656
+ * and every direction set share. Each value is bound ONCE and its `$n` is
657
+ * referenced by every branch that needs it (legal on every supported
658
+ * placeholder syntax), which is what keeps the param count equal to the field
659
+ * count on both the build and the collect side.
660
+ *
661
+ * The former `c1 > $1 AND c2 > $2` was a conjunction, not a seek: it skipped
662
+ * every row whose leading key EQUALED the cursor's, so a table with few
663
+ * distinct leading values lost most of its rows across a page walk, silently.
664
+ */
665
+ private keysetPredicate;
618
666
  /**
619
667
  * Whether a findMany paginates (`limit` / `take` / `offset` / `cursor`) but
620
668
  * declares no ordering, which makes the returned page NON-DETERMINISTIC:
@@ -1307,6 +1355,21 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
1307
1355
  private toColumn;
1308
1356
  /** Convert camelCase field name to a double-quoted SQL identifier */
1309
1357
  private toSqlColumn;
1358
+ /**
1359
+ * The explicit outer SELECT list of the `distinct` + `orderBy` wrapper, used
1360
+ * only when the inner derived table had to carry ORDER BY columns the
1361
+ * projection leaves out (see the distinct branch of {@link buildFindMany}):
1362
+ * the projected base columns, then one column per `with` relation, then one
1363
+ * per counted relation. These are the aliases `buildSelectWithRelations`
1364
+ * (relations.ts) gives the same columns, `AS "<relName>"` per entry in sorted
1365
+ * order and `AS "_count__<rel>"` per counted relation, over the SAME
1366
+ * `resolveCountRelations` it uses, so the two lists name the same columns.
1367
+ * `flatten` never reaches here (the strategy is gated off under `distinct`),
1368
+ * so no prefixed scalar aliases exist to enumerate. A drift between the two
1369
+ * would fail LOUDLY, as a 42703 on the outer list, never as a silently wrong
1370
+ * row, and the live distinct test pins the `with` + `_count` shape.
1371
+ */
1372
+ private distinctOuterSelectList;
1310
1373
  fingerprintWhere(where: Record<string, unknown>): string;
1311
1374
  collectWhereParams(where: Record<string, unknown>, params: unknown[]): void;
1312
1375
  private resolveGlobalFilter;