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
@@ -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/cjs/powql.js CHANGED
@@ -482,6 +482,28 @@ class PowqlInterface {
482
482
  assertIdentifiesOneRow(where) {
483
483
  (0, compound_unique_js_1.assertWhereIdentifiesOneRow)(this.meta, this.table, where);
484
484
  }
485
+ /**
486
+ * The single-row WRITE rule (`update` / `delete` return one row, so their
487
+ * `where` must identify one; `upsert`'s `where` names the row it conflicts
488
+ * on), shared with the SQL engines through query/compound-unique.ts so the
489
+ * two cannot disagree about which writes are valid. Skipped under the
490
+ * explicit full-table opt-in, which already means "every row". For `update` /
491
+ * `delete` it runs AFTER `assertCompiledWhere`, so an empty selector keeps
492
+ * the empty-where message and this only refuses a non-empty one that names no
493
+ * key; `upsert` takes no `allowFullTableScan` and has no empty-where guard to
494
+ * run first, so this is its only where check and it covers `{}` as well.
495
+ */
496
+ assertMutationIdentifiesOneRow(where, allowFullTableScan, operation) {
497
+ if (allowFullTableScan)
498
+ return;
499
+ (0, compound_unique_js_1.assertMutationWhereIdentifiesOneRow)(this.meta, this.table, where, operation);
500
+ }
501
+ /** A caller `where` with a Prisma compound-unique selector expanded to its member columns. */
502
+ expandedWhere(where) {
503
+ if (!where)
504
+ return where;
505
+ return (0, compound_unique_js_1.expandCompoundUniqueWhere)(this.meta, where);
506
+ }
485
507
  assertPagination(limit, offset, context) {
486
508
  for (const [name, value] of [
487
509
  ['limit', limit],
@@ -2701,14 +2723,22 @@ class PowqlInterface {
2701
2723
  async update(args) {
2702
2724
  return this.withMiddleware('update', args, async () => {
2703
2725
  if ((0, nested_write_js_1.hasRelationFields)(args.data, this.meta)) {
2726
+ // The nested engine re-enters `update` / `findUnique` for the parent
2727
+ // row before it writes anything, so the identity rule below still runs
2728
+ // first on this path.
2704
2729
  return this.nestedUpdate(args);
2705
2730
  }
2731
+ const allowFullTableScan = (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan');
2732
+ // Prisma compound-unique selector → column conjunction, so the selector
2733
+ // counts as the key it is (engine parity with the SQL buildUpdate).
2734
+ const userWhere = this.expandedWhere(args.where);
2706
2735
  const params = [];
2707
- const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2736
+ const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout);
2708
2737
  let where = this.buildWhere(resolvedWhere, params);
2709
2738
  // `false` here refused an empty where even WITH the opt-in, while every
2710
2739
  // SQL engine accepted it: verified by probe on buildUpdate/buildDelete.
2711
- this.assertCompiledWhere(where, (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'), 'update');
2740
+ this.assertCompiledWhere(where, allowFullTableScan, 'update');
2741
+ this.assertMutationIdentifiesOneRow(userWhere, allowFullTableScan, 'update');
2712
2742
  where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2713
2743
  let setClause = this.buildUpdateAssignments(args.data, params);
2714
2744
  // Optimistic locking, matching the SQL engines exactly: bump the version
@@ -2882,10 +2912,13 @@ class PowqlInterface {
2882
2912
  }
2883
2913
  async delete(args) {
2884
2914
  return this.withMiddleware('delete', args, async () => {
2915
+ const allowFullTableScan = (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan');
2916
+ const userWhere = this.expandedWhere(args.where);
2885
2917
  const params = [];
2886
- const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
2918
+ const resolvedWhere = await this.resolveRelationFilters(userWhere, args.timeout);
2887
2919
  let where = this.buildWhere(resolvedWhere, params);
2888
- this.assertCompiledWhere(where, (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'), 'delete');
2920
+ this.assertCompiledWhere(where, allowFullTableScan, 'delete');
2921
+ this.assertMutationIdentifiesOneRow(userWhere, allowFullTableScan, 'delete');
2889
2922
  where = this.applyGlobalFilter(where, params, args.skipGlobalFilters);
2890
2923
  // `returning` hands back the deleted row(s), no separate pre-image reselect needed.
2891
2924
  const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
@@ -2909,6 +2942,13 @@ class PowqlInterface {
2909
2942
  }
2910
2943
  async upsert(args) {
2911
2944
  return this.withMiddleware('upsert', args, async () => {
2945
+ // The single-row rule for `upsert`, shared with the SQL engines through
2946
+ // query/compound-unique.ts. PowqlInterface is a PARALLEL implementation,
2947
+ // not a subclass, so a rule adopted only on the SQL side is two engines
2948
+ // disagreeing about whether a query is VALID, which is the failure class
2949
+ // 0.64.0 and 0.72.0 were both spent on. Before `applyPkDefault`, so a
2950
+ // refused upsert mints no client-side UUID and sends nothing.
2951
+ this.assertMutationIdentifiesOneRow(this.expandedWhere(args.where), false, 'upsert');
2912
2952
  const createData = this.applyPkDefault(args.create);
2913
2953
  const pkCol = this.meta.primaryKey[0];
2914
2954
  if (this.meta.primaryKey.length !== 1 || !pkCol) {
@@ -297,6 +297,33 @@ function lookupsFor(ctx, mm) {
297
297
  return l;
298
298
  }
299
299
  /** Resolve a turbine relation's target Prisma model map (for nested translation). */
300
+ /**
301
+ * A `where` fragment no row satisfies, for Prisma's empty `OR`.
302
+ *
303
+ * `{ <key>: { in: [] } }`, which core compiles to `= ANY($n)` with an empty
304
+ * array: false for every row, NULL-valued ones included, and one ordinary bound
305
+ * param rather than a literal spliced into the SQL. The column is the first
306
+ * primary-key column, else the table's first column, so it is always a real
307
+ * column of this table and the predicate stays a plain scalar comparison the
308
+ * cache fingerprints like any other.
309
+ *
310
+ * A table this layer cannot name a column of THROWS. The fallback used to be
311
+ * `{}`, an empty `where` fragment - which is not "no rows" but its exact
312
+ * opposite, EVERY row, and it would have been merged in silently beside the
313
+ * caller's other predicates. A sentinel builder that cannot build a sentinel
314
+ * has not succeeded, and the one shape it exists to compile is the one where
315
+ * answering "all of them" instead of "none of them" is worst.
316
+ */
317
+ function matchNothing(ctx, mm) {
318
+ const meta = ctx.schema.tables[mm.table];
319
+ const column = meta?.primaryKey?.[0] ?? meta?.allColumns?.[0];
320
+ if (!meta || !column) {
321
+ throw new errors_js_1.ValidationError(`An empty \`OR\` on model ${mm.table} means "no rows", and compiling that needs one real column ` +
322
+ `of the table, but the schema metadata for "${mm.table}" lists none. Regenerate the client ` +
323
+ '(`npx turbine generate`) so the table carries its columns, or drop the empty `OR`.');
324
+ }
325
+ return { [meta.reverseColumnMap?.[column] ?? column]: { in: [] } };
326
+ }
300
327
  function relTargetModel(ctx, mm, turbineRel) {
301
328
  const rd = ctx.schema.tables[mm.table]?.relations?.[turbineRel];
302
329
  if (!rd)
@@ -637,8 +664,30 @@ function translateWhere(ctx, mm, where, depth = 0) {
637
664
  if (!isPlainObject(where))
638
665
  return where;
639
666
  const out = {};
667
+ // The empty-`OR` sentinel, held aside rather than merged into `out`. At most
668
+ // one per level, since `OR` is a single object key.
669
+ let matchNone;
640
670
  for (const [key, val] of Object.entries(where)) {
641
671
  if (COMBINATORS.has(key)) {
672
+ // An EMPTY `OR` is false in Prisma (no branch can match), while an empty
673
+ // `AND` / `NOT` is true. Core drops an empty combinator entirely, so the
674
+ // same call returned EVERY row here: the classic trigger is
675
+ // `OR: ids.map(...)` over a selection that turned out to be empty, i.e.
676
+ // "none of these" answered with "all of them". Compiled to a predicate
677
+ // that matches nothing so this layer keeps Prisma's meaning; core's own
678
+ // semantics are unchanged and documented separately.
679
+ //
680
+ // Held aside, NOT `Object.assign`ed onto `out`: the sentinel is keyed on
681
+ // a real column of the table, so merging it made two predicates on one
682
+ // key collide and JavaScript's key ORDER decided which survived.
683
+ // `{ OR: [], id: 5 }` kept the caller's `id = 5` and dropped the
684
+ // sentinel (returning the row Prisma excludes); `{ id: 5, OR: [] }` kept
685
+ // the sentinel and dropped the caller's `id = 5`. Same query, same
686
+ // meaning, opposite predicate.
687
+ if (key === 'OR' && Array.isArray(val) && val.length === 0) {
688
+ matchNone = matchNothing(ctx, mm);
689
+ continue;
690
+ }
642
691
  // An `AND` / `OR` array of N conditions is ONE level, not N: the elements
643
692
  // are siblings, so they all translate at the same incremented depth.
644
693
  out[key] = Array.isArray(val)
@@ -660,6 +709,17 @@ function translateWhere(ctx, mm, where, depth = 0) {
660
709
  // Relation filter.
661
710
  const rel = mm.relations[key];
662
711
  if (rel) {
712
+ // Prisma spells "this to-one relation has no related row" as a bare
713
+ // `null` on the relation key; core spells it `{ is: null }` and answers a
714
+ // bare null with E003. The generic message then degraded into naming the
715
+ // key that was just passed ("Did you mean "user" (a relation)?"), because
716
+ // the adapter had already renamed it. To-many keys are NOT mapped: Prisma
717
+ // has no bare-null shape there, so core still refuses them by name rather
718
+ // than this layer inventing a meaning.
719
+ if (val === null && rel.cardinality === 'one') {
720
+ out[rel.name] = { is: null };
721
+ continue;
722
+ }
663
723
  const target = relTargetModel(ctx, mm, rel.name);
664
724
  out[rel.name] = translateRelationFilter(ctx, target, val, depth + 1);
665
725
  continue;
@@ -668,7 +728,20 @@ function translateWhere(ctx, mm, where, depth = 0) {
668
728
  // through unchanged (Prisma operator names match Turbine's).
669
729
  out[renameField(mm, key)] = val;
670
730
  }
671
- return out;
731
+ if (!matchNone)
732
+ return out;
733
+ // Nothing else at this level: the sentinel IS the where, no wrapper needed.
734
+ if (Object.keys(out).length === 0)
735
+ return matchNone;
736
+ // Otherwise conjoin, so neither half can overwrite the other whatever order
737
+ // the caller wrote the keys in. BRANDED, because an `AND` array is the shape
738
+ // that makes a statement unnamed (a caller-written combinator has a
739
+ // caller-sized arity); this one is Turbine's, with a fixed arity of two, and
740
+ // an unbranded wrapper here would quietly take every compat query carrying an
741
+ // empty `OR` off named prepared statements. The caller's own combinators sit
742
+ // INSIDE `out` and are walked at the next level with their own brand check,
743
+ // so they still count.
744
+ return (0, utils_js_1.markInternalCombinator)({ AND: [out, matchNone] });
672
745
  }
673
746
  function translateRelationFilter(ctx, target, val, depth) {
674
747
  if (!isPlainObject(val))
@@ -1198,15 +1271,29 @@ function translateCursor(ctx, mm, prismaArgs, t) {
1198
1271
  const op = desc ? 'lte' : 'gte';
1199
1272
  t.where = mergeKeyset(t.where ?? {}, field, op, value);
1200
1273
  }
1201
- /** Flatten a Turbine orderBy (object or single-object array) into [field, isDesc] pairs. */
1274
+ /**
1275
+ * Flatten a Turbine orderBy (object, or an array of them) into [field, isDesc]
1276
+ * pairs.
1277
+ *
1278
+ * EVERY array element is read. Reading only an array of length one returned
1279
+ * `[]` for a two-element array, and `[]` is also what "no orderBy at all"
1280
+ * returns, so {@link translateCursor} took its no-orderBy branch: it found a
1281
+ * bare cursor on the primary key, wrote `orderBy = { id: 'asc' }` over the
1282
+ * caller's array, and returned the rows following the anchor in PK order
1283
+ * instead of in the caller's order. The single-sort-key check downstream is
1284
+ * what refuses a shape a bare (inclusive) cursor cannot express, and it can
1285
+ * only fire if the pairs it counts are all there.
1286
+ */
1202
1287
  function orderByPairs(ob) {
1203
- const one = Array.isArray(ob) ? (ob.length === 1 ? ob[0] : undefined) : ob;
1204
- if (!isPlainObject(one))
1205
- return [];
1288
+ const entries = Array.isArray(ob) ? ob : [ob];
1206
1289
  const out = [];
1207
- for (const [k, v] of Object.entries(one)) {
1208
- const dir = isPlainObject(v) ? v.sort : v;
1209
- out.push([k, dir === 'desc']);
1290
+ for (const entry of entries) {
1291
+ if (!isPlainObject(entry))
1292
+ continue;
1293
+ for (const [k, v] of Object.entries(entry)) {
1294
+ const dir = isPlainObject(v) ? v.sort : v;
1295
+ out.push([k, dir === 'desc']);
1296
+ }
1210
1297
  }
1211
1298
  return out;
1212
1299
  }
@@ -254,7 +254,7 @@ function buildGroupBy(qi, args) {
254
254
  // would emit two "_sum_total_price" columns and silently drop one.
255
255
  claimResultKey(`${aggKey}_${col}`, `${aggKey} of column "${col}"`);
256
256
  const inner = `${sqlFn}(${qi.q(col)})`;
257
- const expr = aggKey === '_avg' ? qi.castAgg(inner, 'float') : inner;
257
+ const expr = aggKey === '_avg' ? plainAvgExpr(qi, col) : inner;
258
258
  selectExprs.push(`${expr} AS ${qi.q(`${aggKey}_${col}`)}`);
259
259
  // Canonical field, matching the result bucket the transform fills;
260
260
  // `orderBy` may spell it either way (see {@link lookupGroupKey}).
@@ -365,13 +365,17 @@ function buildGroupBy(qi, args) {
365
365
  // aggregates keep the snake→camel field mapping.
366
366
  const jsonAgg = (rawKey) => jsonAggFields.get(rawKey);
367
367
  const fieldFor = (rawKey, col) => jsonAgg(rawKey)?.field ?? qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
368
+ // A JSON-path `_sum` / `_avg` casts its extracted text to numeric in
369
+ // SQL and is always a number; a plain column follows its source type
370
+ // (see {@link isExactNumericType}).
371
+ const sumAvg = (rawKey, rawValue) => jsonAgg(rawKey) ? (rawValue !== null ? Number(rawValue) : null) : sumAvgValue(qi, rawKey.slice(5), rawValue);
368
372
  for (const [rawKey, rawValue] of Object.entries(row)) {
369
373
  if (rawKey.startsWith('_sum_')) {
370
- sumObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
374
+ sumObj[fieldFor(rawKey, rawKey.slice(5))] = sumAvg(rawKey, rawValue);
371
375
  hasSums = true;
372
376
  }
373
377
  else if (rawKey.startsWith('_avg_')) {
374
- avgObj[fieldFor(rawKey, rawKey.slice(5))] = rawValue !== null ? Number(rawValue) : null;
378
+ avgObj[fieldFor(rawKey, rawKey.slice(5))] = sumAvg(rawKey, rawValue);
375
379
  hasAvgs = true;
376
380
  }
377
381
  else if (rawKey.startsWith('_min_')) {
@@ -926,6 +930,52 @@ function temporalAggValue(qi, col, value) {
926
930
  return qi.utcTimestamps !== false ? (0, utils_js_1.parseDbDate)(value) : new Date(value);
927
931
  return value;
928
932
  }
933
+ /**
934
+ * Whether `SUM` / `AVG` over a column of `pgType` is EXACT on the wire and must
935
+ * not be narrowed to a JS number.
936
+ *
937
+ * PostgreSQL widens both past their input: over int8 they are `numeric`, over
938
+ * numeric they stay `numeric`. (An int2 / int4 sum is int8 and its average
939
+ * `numeric` too, but those totals fit a double, which is the line this draws.)
940
+ * The driver delivers `numeric` as its exact text, because no parser is
941
+ * registered for it, deliberately: the type is arbitrary-precision. `Number()`
942
+ * over that text rounded a `SUM(int8)` of 461168601842738790350 to
943
+ * 461168601842738800000 and a numeric(12,2) total of 1020.50 to 1020.5, while
944
+ * `_min` / `_max` on the very same columns came back exact, because they hand
945
+ * the driver's value through untouched.
946
+ *
947
+ * So for these source types the aggregate is returned as the driver delivered
948
+ * it (on PostgreSQL the text, a string; an engine whose driver already hands
949
+ * back a number keeps that number) and `_avg` is not cast to float in SQL,
950
+ * which would otherwise round on the server before the value reached the
951
+ * wire. Every other type keeps `Number()` and the cast. Spelled for every
952
+ * engine's type names: the PostgreSQL `udt_name` (`int8`, `numeric`) and the
953
+ * SQL names the other dialects report (`bigint`, `decimal`), with case and any
954
+ * `(precision, scale)` suffix ignored. JSON-path aggregates never reach this:
955
+ * they cast the extracted text to numeric themselves and stay numbers.
956
+ */
957
+ function isExactNumericType(pgType) {
958
+ const paren = pgType.indexOf('(');
959
+ const base = (paren === -1 ? pgType : pgType.slice(0, paren)).trim().toLowerCase();
960
+ return base === 'int8' || base === 'bigint' || base === 'numeric' || base === 'decimal';
961
+ }
962
+ /** `AVG(col)`, float-cast unless the source column is exact (see {@link isExactNumericType}). */
963
+ function plainAvgExpr(qi, col) {
964
+ const inner = `AVG(${qi.q(col)})`;
965
+ return isExactNumericType(whereMod.getColumnPgType(qi, col)) ? inner : qi.castAgg(inner, 'float');
966
+ }
967
+ /**
968
+ * A `_sum` / `_avg` value over a plain column: the driver's value verbatim
969
+ * for an exact source type (see {@link isExactNumericType}), a JS number for
970
+ * every other, `null` for an aggregate over zero rows.
971
+ */
972
+ function sumAvgValue(qi, col, value) {
973
+ if (value === null || value === undefined)
974
+ return null;
975
+ if (isExactNumericType(whereMod.getColumnPgType(qi, col)))
976
+ return value;
977
+ return Number(value);
978
+ }
929
979
  function buildAggregate(qi, args) {
930
980
  qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
931
981
  // Resolved once, up front: see buildGroupBy.
@@ -987,7 +1037,7 @@ function buildAggregate(qi, args) {
987
1037
  for (const [field, enabled] of Object.entries(args._avg)) {
988
1038
  if (enabled) {
989
1039
  const col = qi.toColumn(field);
990
- selectExprs.push(`${qi.castAgg(`AVG(${qi.q(col)})`, 'float')} AS ${qi.q(`_avg_${col}`)}`);
1040
+ selectExprs.push(`${plainAvgExpr(qi, col)} AS ${qi.q(`_avg_${col}`)}`);
991
1041
  }
992
1042
  }
993
1043
  }
@@ -1057,13 +1107,13 @@ function buildAggregate(qi, args) {
1057
1107
  if (key.startsWith('_sum_')) {
1058
1108
  const col = key.slice(5);
1059
1109
  const field = qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1060
- sumObj[field] = val !== null ? Number(val) : null;
1110
+ sumObj[field] = sumAvgValue(qi, col, val);
1061
1111
  hasSums = true;
1062
1112
  }
1063
1113
  else if (key.startsWith('_avg_')) {
1064
1114
  const col = key.slice(5);
1065
1115
  const field = qi.tableMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col);
1066
- avgObj[field] = val !== null ? Number(val) : null;
1116
+ avgObj[field] = sumAvgValue(qi, col, val);
1067
1117
  hasAvgs = true;
1068
1118
  }
1069
1119
  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;