turbine-orm 0.51.0 → 0.52.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 (74) hide show
  1. package/README.md +33 -5
  2. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  3. package/dist/cjs/client.d.ts +106 -2
  4. package/dist/cjs/client.js +111 -5
  5. package/dist/cjs/dialect.d.ts +33 -0
  6. package/dist/cjs/dialect.js +14 -0
  7. package/dist/cjs/engine-config.d.ts +49 -0
  8. package/dist/cjs/engine-config.js +19 -0
  9. package/dist/cjs/index-advisor.js +0 -0
  10. package/dist/cjs/index.d.ts +1 -1
  11. package/dist/cjs/index.js +3 -2
  12. package/dist/cjs/mssql.d.ts +8 -3
  13. package/dist/cjs/mssql.js +22 -3
  14. package/dist/cjs/mysql.d.ts +7 -3
  15. package/dist/cjs/mysql.js +20 -3
  16. package/dist/cjs/nested-write.d.ts +31 -0
  17. package/dist/cjs/nested-write.js +80 -2
  18. package/dist/cjs/powdb-introspect.d.ts +10 -1
  19. package/dist/cjs/powdb-introspect.js +10 -1
  20. package/dist/cjs/powdb.d.ts +116 -6
  21. package/dist/cjs/powdb.js +169 -10
  22. package/dist/cjs/powql.d.ts +161 -1
  23. package/dist/cjs/powql.js +299 -19
  24. package/dist/cjs/prisma-compat.d.ts +54 -8
  25. package/dist/cjs/prisma-compat.js +136 -20
  26. package/dist/cjs/query/batched-loader.d.ts +7 -0
  27. package/dist/cjs/query/batched-loader.js +97 -15
  28. package/dist/cjs/query/builder.d.ts +131 -5
  29. package/dist/cjs/query/builder.js +223 -19
  30. package/dist/cjs/query/compound-unique.js +0 -0
  31. package/dist/cjs/query/index.d.ts +1 -1
  32. package/dist/cjs/query/index.js +2 -1
  33. package/dist/cjs/query/warn-registry.d.ts +10 -0
  34. package/dist/cjs/query/warn-registry.js +10 -0
  35. package/dist/cjs/query/writes.js +115 -7
  36. package/dist/cjs/sqlite.d.ts +10 -4
  37. package/dist/cjs/sqlite.js +18 -4
  38. package/dist/cli/studio-ui.generated.js +1 -1
  39. package/dist/client.d.ts +106 -2
  40. package/dist/client.js +111 -5
  41. package/dist/dialect.d.ts +33 -0
  42. package/dist/dialect.js +14 -0
  43. package/dist/engine-config.d.ts +49 -0
  44. package/dist/engine-config.js +18 -0
  45. package/dist/index-advisor.js +0 -0
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.js +1 -1
  48. package/dist/mssql.d.ts +8 -3
  49. package/dist/mssql.js +22 -3
  50. package/dist/mysql.d.ts +7 -3
  51. package/dist/mysql.js +20 -3
  52. package/dist/nested-write.d.ts +31 -0
  53. package/dist/nested-write.js +79 -2
  54. package/dist/powdb-introspect.d.ts +10 -1
  55. package/dist/powdb-introspect.js +10 -1
  56. package/dist/powdb.d.ts +116 -6
  57. package/dist/powdb.js +167 -9
  58. package/dist/powql.d.ts +161 -1
  59. package/dist/powql.js +299 -19
  60. package/dist/prisma-compat.d.ts +54 -8
  61. package/dist/prisma-compat.js +136 -20
  62. package/dist/query/batched-loader.d.ts +7 -0
  63. package/dist/query/batched-loader.js +98 -16
  64. package/dist/query/builder.d.ts +131 -5
  65. package/dist/query/builder.js +222 -18
  66. package/dist/query/compound-unique.js +0 -0
  67. package/dist/query/index.d.ts +1 -1
  68. package/dist/query/index.js +1 -1
  69. package/dist/query/warn-registry.d.ts +10 -0
  70. package/dist/query/warn-registry.js +10 -0
  71. package/dist/query/writes.js +116 -8
  72. package/dist/sqlite.d.ts +10 -4
  73. package/dist/sqlite.js +19 -5
  74. package/package.json +3 -3
package/dist/powql.js CHANGED
@@ -37,7 +37,7 @@
37
37
  import { randomUUID } from 'node:crypto';
38
38
  import { NotFoundError, ReadOnlyError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
39
39
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
40
- import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
40
+ import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isPowdbDatetimeColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
41
41
  import { assertAggregatePiiOptIn } from './query/aggregates.js';
42
42
  import { expandCompoundUniqueWhere } from './query/compound-unique.js';
43
43
  import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/filters.js';
@@ -51,6 +51,18 @@ import { normalizeKeyColumns, snakeToCamel, } from './schema.js';
51
51
  * before grouping. Mirrors the chunking the parity matrix documents.
52
52
  */
53
53
  const MAX_RELATION_KEYS = 1000;
54
+ /**
55
+ * Max values one `in` / `notIn` on a PowDB-native `datetime` column may carry.
56
+ *
57
+ * Such a list is never sent as a list: it is expanded into an equality chain
58
+ * (see {@link PowqlInterface.buildDatetimeInList}), and PowQL spends one level
59
+ * of its 64-level nesting budget per chain term. Measured on the 0.20.0 addon:
60
+ * 63 terms parse at the top level, 61 one level deep, whatever else the
61
+ * predicate contains. 32 leaves room for the surrounding filter, and is also the
62
+ * key-chunk size the relation loaders use for a datetime correlation column, so
63
+ * a loader can never build a chain the engine will reject.
64
+ */
65
+ export const MAX_POWQL_DATETIME_TERMS = 32;
54
66
  /**
55
67
  * Read-shaped actions whose statement may be transparently replayed once on a
56
68
  * stale wire frame when `retryStaleReads` is enabled (see
@@ -260,6 +272,36 @@ export class PowqlInterface {
260
272
  get capabilities() {
261
273
  return this.pool.capabilities ?? ALL_POWDB_CAPABILITIES;
262
274
  }
275
+ /**
276
+ * The `limit` a query actually emits: the explicit `limit`, Prisma's `take`
277
+ * alias, then the client-level `defaultLimit`. Shared by {@link buildFind} and
278
+ * the {@link findMany} zero short-circuit so the two can never disagree about
279
+ * which limit is in force.
280
+ */
281
+ effectiveLimit(args) {
282
+ return args.limit ?? args.take ?? this.defaultLimit;
283
+ }
284
+ /**
285
+ * Reject a negative `limit` / `offset` before it reaches the engine. PowDB
286
+ * casts both with `as usize` at execution, so below engine 0.20 a negative
287
+ * limit wrapped to `usize::MAX` and silently returned EVERY row, the opposite
288
+ * of what the caller asked for; 0.20 refuses it. Validating client-side makes
289
+ * the refusal identical on every engine version and names the argument.
290
+ *
291
+ * `limit: 0` is legal and means "no rows" (SQL `LIMIT 0`). It is not emitted:
292
+ * callers short-circuit it, because PowDB's projection fast path returned ONE
293
+ * row for `limit 0` below 0.20.
294
+ */
295
+ assertPagination(limit, offset, context) {
296
+ for (const [name, value] of [
297
+ ['limit', limit],
298
+ ['offset', offset],
299
+ ]) {
300
+ if (value !== undefined && value !== null && value < 0) {
301
+ throw new ValidationError(`[turbine] ${context} on "${this.table}": \`${name}\` must not be negative (got ${value}).`);
302
+ }
303
+ }
304
+ }
263
305
  /** A predicate that is always false, the empty-`in` / contradiction sentinel. */
264
306
  alwaysFalse() {
265
307
  const pk = this.meta.primaryKey[0] ?? this.meta.columns[0]?.name;
@@ -317,6 +359,43 @@ export class PowqlInterface {
317
359
  }
318
360
  return parts.join(' and ');
319
361
  }
362
+ /**
363
+ * Gate a predicate on a PowDB-native `datetime` column. Turbine binds a JS
364
+ * `Date` as an integer count of microseconds, and PowDB writes a timestamp
365
+ * literal as a plain integer, so every such predicate is a `DateTime` vs `Int`
366
+ * comparison. Below engine 0.20 that pairing was unhandled and fell back to
367
+ * comparing TYPE TAGS, so `>` matched every non-null row, `=` and `<` matched
368
+ * none, and the answer additionally changed with the column's access path
369
+ * (indexed vs scanned). 0.20 compares microseconds and every binary operator
370
+ * (`=`, `!=`, `<`, `<=`, `>`, `>=`) is correct.
371
+ *
372
+ * ONE gate covers the whole family, whatever spelling the caller used. `in` /
373
+ * `not in` are included because their still-broken LIST form is never emitted:
374
+ * {@link buildInList} expands a datetime list into the equality chain the
375
+ * engine does answer correctly, which is the same binary comparison this flag
376
+ * governs. So on >= 0.20 every path is served (direct predicates, relation
377
+ * filters, the batched loaders, nested projections, native joins, and the
378
+ * findUnique / update / delete / upsert by-key paths), and below 0.20 every
379
+ * path that compares against a literal is refused with one message, which
380
+ * names the read paths that do not.
381
+ *
382
+ * `is null` / `is not null` are never gated: they compare no literal and are
383
+ * correct on every version. Ordering, grouping and `min`/`max` are likewise
384
+ * unaffected (they compare datetimes against each other, never against an int),
385
+ * as are nested-projection and join correlations, which are column-to-column.
386
+ *
387
+ * Only PowDB's native `datetime` type is affected. Turbine's own DDL emits
388
+ * `int` epoch micros for a `Date` column, so a Turbine-provisioned database
389
+ * never reaches this: the exposed shape is a table created outside Turbine and
390
+ * read through `introspectPowdbDatabase`, or hand-written metadata.
391
+ */
392
+ assertDatetimePredicateSupported(col) {
393
+ if (!isPowdbDatetimeColumn(col))
394
+ return;
395
+ requireCapability(this.capabilities, 'datetimeCompare', `comparisons on the PowDB datetime column "${col.name}"`, 'Reading that column is unaffected, and so are ordering, grouping and `is null` checks; only comparing it ' +
396
+ 'against a bound timestamp needs the fix. A nested `with` (the default nested-projection / join paths) ' +
397
+ 'correlates column to column, so it loads the relation without any such comparison.');
398
+ }
320
399
  /** Build a single `field: value | operator` condition. */
321
400
  buildFieldCondition(field, value, params, alias) {
322
401
  const colMeta = this.column(field);
@@ -324,6 +403,7 @@ export class PowqlInterface {
324
403
  if (value === null)
325
404
  return `${ref} is null`;
326
405
  if (value instanceof Date || typeof value !== 'object') {
406
+ this.assertDatetimePredicateSupported(colMeta);
327
407
  return `${ref} = ${this.param(value, params, colMeta)}`;
328
408
  }
329
409
  const op = value;
@@ -338,6 +418,7 @@ export class PowqlInterface {
338
418
  rejectUnsupportedFilter(op, field);
339
419
  if (!Object.keys(op).some((k) => OPERATOR_KEYS.has(k))) {
340
420
  // A bare object that is not an operator set, equality by value.
421
+ this.assertDatetimePredicateSupported(colMeta);
341
422
  return `${ref} = ${this.param(value, params)}`;
342
423
  }
343
424
  const insensitive = op.mode === 'insensitive';
@@ -346,6 +427,17 @@ export class PowqlInterface {
346
427
  for (const [opName, opVal] of Object.entries(op)) {
347
428
  if (opVal === undefined || opName === 'mode')
348
429
  continue;
430
+ // Every operator below compares the column against a bound literal, which
431
+ // is the shape a PowDB `datetime` column can only answer from engine 0.20
432
+ // (see assertDatetimePredicateSupported; `in`/`notIn` reach the engine as
433
+ // an equality chain, so they are the same comparison). The two exceptions
434
+ // compare nothing and stay allowed on every version: a null operand
435
+ // (`equals`/`not: null` → `is [not] null`) and an empty `in`/`notIn` list
436
+ // (a compile-time constant, never sent).
437
+ const comparesNothing = (opVal === null && (opName === 'equals' || opName === 'not')) ||
438
+ ((opName === 'in' || opName === 'notIn') && Array.isArray(opVal) && opVal.length === 0);
439
+ if (!comparesNothing)
440
+ this.assertDatetimePredicateSupported(colMeta);
349
441
  switch (opName) {
350
442
  case 'equals':
351
443
  conds.push(opVal === null ? `${ref} is null` : `${lhs} = ${this.bind(opVal, params, insensitive)}`);
@@ -370,10 +462,10 @@ export class PowqlInterface {
370
462
  conds.push(`${lhs} <= ${this.bind(opVal, params, insensitive)}`);
371
463
  break;
372
464
  case 'in':
373
- conds.push(this.buildInList(lhs, opVal, params, insensitive, false));
465
+ conds.push(this.buildInList(lhs, opVal, params, insensitive, false, colMeta));
374
466
  break;
375
467
  case 'notIn':
376
- conds.push(this.buildInList(lhs, opVal, params, insensitive, true));
468
+ conds.push(this.buildInList(lhs, opVal, params, insensitive, true, colMeta));
377
469
  break;
378
470
  case 'contains':
379
471
  conds.push(`${lhs} like ${this.bindLike(`%${escapeLike(String(opVal))}%`, params, insensitive)}`);
@@ -510,13 +602,20 @@ export class PowqlInterface {
510
602
  const ph = this.param(pattern, params);
511
603
  return insensitive ? `lower(${ph})` : ph;
512
604
  }
513
- /** `lhs [not] in ($1, $2, …)`, empty list collapses to a constant. */
514
- buildInList(lhs, values, params, insensitive, negate) {
605
+ /**
606
+ * `lhs [not] in ($1, $2, …)`, empty list collapses to a constant.
607
+ *
608
+ * A PowDB-native `datetime` column takes the expanded form instead (see
609
+ * {@link buildDatetimeInList}): its `in` list is still broken upstream at 0.20.
610
+ */
611
+ buildInList(lhs, values, params, insensitive, negate, col) {
515
612
  if (!Array.isArray(values) || values.length === 0) {
516
613
  // `in []` matches nothing; `not in []` matches everything (SQL parity requires a
517
614
  // missing-value row to match `notIn []`, so NO presence guard is appended here).
518
615
  return negate ? '(1 = 1)' : this.alwaysFalse();
519
616
  }
617
+ if (col && isPowdbDatetimeColumn(col))
618
+ return this.buildDatetimeInList(lhs, values, params, negate, col);
520
619
  const items = values.map((v) => this.bind(v, params, insensitive)).join(', ');
521
620
  if (negate) {
522
621
  // PowQL `not in` matches missing-value rows, so append `and lhs is not null`
@@ -525,6 +624,78 @@ export class PowqlInterface {
525
624
  }
526
625
  return `${lhs} in (${items})`;
527
626
  }
627
+ /**
628
+ * `in` / `not in` on a PowDB-native `datetime` column, expanded into the
629
+ * equality chain the engine answers correctly:
630
+ *
631
+ * `in` → `(.ts = $1 or .ts = $2 …)`
632
+ * `notIn` → `(.ts != $1 and .ts != $2 … and .ts is not null)`
633
+ *
634
+ * PowQL's LIST form compares a datetime column against integer timestamp
635
+ * literals by TYPE TAG as of engine 0.20 (the 0.20 timestamp fix covered the
636
+ * binary operators only): measured on the 0.20.0 addon, `filter .ts in
637
+ * (<micros>, …)` matches nothing and `not in` matches everything, while the
638
+ * identical lists against an `int` control column answer correctly. The
639
+ * expanded chain uses the operators 0.20 DID fix, so it answers correctly and
640
+ * matches the int control exactly.
641
+ *
642
+ * This is what keeps the relation family coherent: relation filters and the
643
+ * batched loaders both compile to a key `in` list, so without the rewrite the
644
+ * same relation was refused through one strategy and served through another
645
+ * (nested projections and joins correlate column to column and never emit a
646
+ * list at all).
647
+ *
648
+ * The cost is the chain's width. PowQL bounds the SHAPE of the predicate tree,
649
+ * and a flat `or` / `and` chain spends one level per term against the same
650
+ * 64-level budget as nested parens (measured on 0.20: 63 terms at the top
651
+ * level, 61 one level deep), so the expansion is capped at
652
+ * {@link MAX_POWQL_DATETIME_TERMS} with headroom for whatever predicate it
653
+ * sits inside. The loaders chunk their key lists to that cap, so only a
654
+ * caller-written list (or a relation filter matching very many distinct
655
+ * timestamps) can exceed it, and that raises a typed E017 saying so rather
656
+ * than an engine parse failure.
657
+ */
658
+ buildDatetimeInList(lhs, values, params, negate, col) {
659
+ if (values.length > MAX_POWQL_DATETIME_TERMS) {
660
+ throw new UnsupportedFeatureError(`\`${negate ? 'notIn' : 'in'}\` with ${values.length} values on the PowDB datetime column "${col.name}"`, 'PowDB', `PowQL's \`in\` list still compares a datetime column against integer timestamp literals by type tag as of ` +
661
+ `engine 0.20, so Turbine expands it into an equality chain, which PowQL's nesting budget caps at ` +
662
+ `${MAX_POWQL_DATETIME_TERMS} terms. Narrow the list (a \`gte\`/\`lte\` range over the same timestamps is ` +
663
+ 'one comparison), split the call and merge the results, or store the column as a PowQL `int` of epoch ' +
664
+ "microseconds, which is what Turbine's own DDL emits for a `Date` column. A relation filter reaches this " +
665
+ 'when the inner predicate matches more than that many distinct key timestamps.');
666
+ }
667
+ const terms = values.map((v) => `${lhs} ${negate ? '!=' : '='} ${this.param(v, params, col)}`);
668
+ // `!=` already excludes a missing-value row, but the trailing presence guard
669
+ // keeps the emitted predicate identical in meaning to the plain `not in`
670
+ // branch above (and to SQL null semantics) on every engine version.
671
+ if (negate)
672
+ terms.push(`${lhs} is not null`);
673
+ return `(${terms.join(negate ? ' and ' : ' or ')})`;
674
+ }
675
+ /**
676
+ * A literal `ref in (…)` clause for the hand-built key lists the relation
677
+ * loaders emit (they bypass {@link buildWhere}). Routes a PowDB-native
678
+ * `datetime` key column through the same equality-chain expansion the
679
+ * where-builder uses, so a datetime junction / correlation key behaves
680
+ * identically however the statement was assembled.
681
+ */
682
+ inClause(ref, values, params, col) {
683
+ if (col && isPowdbDatetimeColumn(col)) {
684
+ this.assertDatetimePredicateSupported(col);
685
+ return this.buildDatetimeInList(ref, values, params, false, col);
686
+ }
687
+ return `${ref} in (${values.map((v) => this.param(v, params, col)).join(', ')})`;
688
+ }
689
+ /**
690
+ * Key-chunk size for a relation loader. A PowDB-native `datetime` correlation
691
+ * column's `in` list is expanded into an equality chain, which PowQL's nesting
692
+ * budget bounds, so those keys chunk at {@link MAX_POWQL_DATETIME_TERMS}
693
+ * (more, smaller round-trips) instead of {@link MAX_RELATION_KEYS}.
694
+ */
695
+ keyChunkSize(meta, colName) {
696
+ const col = meta?.columns.find((c) => c.name === colName);
697
+ return col && isPowdbDatetimeColumn(col) ? MAX_POWQL_DATETIME_TERMS : MAX_RELATION_KEYS;
698
+ }
528
699
  /**
529
700
  * Pre-resolve every relation filter (`some`/`none`/`every`) in a where clause
530
701
  * into a plain scalar `in`/`notIn` condition on the **local key**, by running
@@ -639,15 +810,18 @@ export class PowqlInterface {
639
810
  return [...new Set(rows.map((r) => r[targetPkField]).filter((v) => v != null))];
640
811
  };
641
812
  // Junction source keys linking any of `targetPks` (literal IN-list, never a subquery).
813
+ const junctionMeta = this.schema.tables[through.table];
814
+ const targetJColMeta = junctionMeta?.columns.find((c) => c.name === targetJCol);
815
+ const junctionChunk = this.keyChunkSize(junctionMeta, targetJCol);
642
816
  const sourcesForTargets = async (targetPks) => {
643
817
  if (!targetPks.length)
644
818
  return [];
645
819
  const out = new Set();
646
- for (let i = 0; i < targetPks.length; i += MAX_RELATION_KEYS) {
647
- const chunk = targetPks.slice(i, i + MAX_RELATION_KEYS);
820
+ for (let i = 0; i < targetPks.length; i += junctionChunk) {
821
+ const chunk = targetPks.slice(i, i + junctionChunk);
648
822
  const params = [];
649
- const ph = chunk.map((v) => this.param(v, params)).join(', ');
650
- const { rows } = await this.exec(`${quotePowqlIdent(through.table)} filter .${targetJCol} in (${ph}) { .${sourceJCol} }`, params, timeout, 'findMany');
823
+ const keyClause = this.inClause(`.${targetJCol}`, chunk, params, targetJColMeta);
824
+ const { rows } = await this.exec(`${quotePowqlIdent(through.table)} filter ${keyClause} { .${sourceJCol} }`, params, timeout, 'findMany');
651
825
  for (const r of rows) {
652
826
  const v = r[sourceJCol];
653
827
  if (v != null)
@@ -948,6 +1122,13 @@ export class PowqlInterface {
948
1122
  // -------------------------------------------------------------------------
949
1123
  async findMany(args = {}) {
950
1124
  return this.withMiddleware('findMany', args, async () => {
1125
+ // `limit: 0` means "no rows" (SQL `LIMIT 0`), and answering it client-side
1126
+ // is correct on every engine version: PowDB's projection fast path returned
1127
+ // ONE row for `limit 0` below 0.20. Validate first so a negative limit still
1128
+ // raises instead of falling through to a query.
1129
+ this.assertPagination(this.effectiveLimit(args), args.offset, 'findMany');
1130
+ if (this.effectiveLimit(args) === 0)
1131
+ return [];
951
1132
  const { rows, native, resolvedWhere, nestedPlans, linkPlans, residualWith } = await this.runFind(args, 'findMany');
952
1133
  const entities = this.shape(rows, native);
953
1134
  if (nestedPlans.length)
@@ -1019,7 +1200,8 @@ export class PowqlInterface {
1019
1200
  const distinct = args.distinct?.length ? ' distinct' : '';
1020
1201
  const filter = where ? ` filter ${where}` : '';
1021
1202
  const order = this.buildOrder(args.orderBy, params, alias);
1022
- const limit = args.limit ?? args.take ?? this.defaultLimit;
1203
+ const limit = this.effectiveLimit(args);
1204
+ this.assertPagination(limit, args.offset, 'findMany');
1023
1205
  if (limit === undefined && this.warnOnUnlimited && !this.warnedUnlimited) {
1024
1206
  this.warnedUnlimited = true;
1025
1207
  console.warn(`[turbine] findMany on "${this.table}" has no limit: this scans the whole table.`);
@@ -1218,8 +1400,9 @@ export class PowqlInterface {
1218
1400
  // cell) so a datetime correlation column stitches instead of silently
1219
1401
  // returning [].
1220
1402
  const childByKey = new Map();
1221
- for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS) {
1222
- const chunk = keys.slice(i, i + MAX_RELATION_KEYS);
1403
+ const chunkSize = this.keyChunkSize(targetMeta, childKeyCol);
1404
+ for (let i = 0; i < keys.length; i += chunkSize) {
1405
+ const chunk = keys.slice(i, i + chunkSize);
1223
1406
  const childWhere = {
1224
1407
  ...fetchOptions.where,
1225
1408
  [childKeyField]: { in: chunk },
@@ -1295,13 +1478,16 @@ export class PowqlInterface {
1295
1478
  return;
1296
1479
  }
1297
1480
  // (1) Junction rows: sourceKeyVal(String) → [targetKeyVal(String)].
1481
+ const junctionMeta = this.schema.tables[through.table];
1482
+ const sourceJColMeta = junctionMeta?.columns.find((c) => c.name === sourceJCol);
1298
1483
  const targetsBySource = new Map();
1299
1484
  const allTargetVals = new Set();
1300
- for (let i = 0; i < parentKeys.length; i += MAX_RELATION_KEYS) {
1301
- const chunk = parentKeys.slice(i, i + MAX_RELATION_KEYS);
1485
+ const junctionChunk = this.keyChunkSize(junctionMeta, sourceJCol);
1486
+ for (let i = 0; i < parentKeys.length; i += junctionChunk) {
1487
+ const chunk = parentKeys.slice(i, i + junctionChunk);
1302
1488
  const params = [];
1303
- const placeholders = chunk.map((v) => this.param(v, params)).join(', ');
1304
- const powql = `${quotePowqlIdent(through.table)} filter .${sourceJCol} in (${placeholders}) { .${sourceJCol}, .${targetJCol} }`;
1489
+ const keyClause = this.inClause(`.${sourceJCol}`, chunk, params, sourceJColMeta);
1490
+ const powql = `${quotePowqlIdent(through.table)} filter ${keyClause} { .${sourceJCol}, .${targetJCol} }`;
1305
1491
  const { rows } = await this.exec(powql, params, timeout, 'findMany');
1306
1492
  for (const row of rows) {
1307
1493
  const sv = String(row[sourceJCol]);
@@ -1319,8 +1505,9 @@ export class PowqlInterface {
1319
1505
  const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1320
1506
  const targetByPk = new Map();
1321
1507
  const targetValList = [...allTargetVals].map((v) => targetPkColMeta ? coerceScalar(v, targetPkColMeta.tsType) : v);
1322
- for (let i = 0; i < targetValList.length; i += MAX_RELATION_KEYS) {
1323
- const chunk = targetValList.slice(i, i + MAX_RELATION_KEYS);
1508
+ const targetChunk = this.keyChunkSize(targetMeta, targetPkCol);
1509
+ for (let i = 0; i < targetValList.length; i += targetChunk) {
1510
+ const chunk = targetValList.slice(i, i + targetChunk);
1324
1511
  const where = {
1325
1512
  ...options.where,
1326
1513
  [targetPkField]: { in: chunk },
@@ -1430,6 +1617,11 @@ export class PowqlInterface {
1430
1617
  if ((options.limit !== undefined || options.offset) && (single || parentCount > MAX_RELATION_KEYS)) {
1431
1618
  return false;
1432
1619
  }
1620
+ // A `limit 0` relation stays off the join statement for the same reason it
1621
+ // stays off a nested projection: PowDB answered `limit 0` with one row below
1622
+ // engine 0.20. The loader resolves it client-side, correctly on every version.
1623
+ if (options.limit === 0)
1624
+ return false;
1433
1625
  return true;
1434
1626
  }
1435
1627
  /**
@@ -1471,6 +1663,7 @@ export class PowqlInterface {
1471
1663
  const childCols = this.joinChildCols(targetQi, options, includePii);
1472
1664
  const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
1473
1665
  const order = targetQi.buildOrder(options.orderBy, params, 'c');
1666
+ this.assertPagination(options.limit, options.offset, `relation "${relName}"`);
1474
1667
  const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
1475
1668
  const offsetClause = options.offset ? ` offset ${this.param(options.offset, params)}` : '';
1476
1669
  const proj = this.joinProjection(childCols, `p.${quotePowqlIdent(parentKeyCol)}`, 'c');
@@ -1625,6 +1818,10 @@ export class PowqlInterface {
1625
1818
  * - m2m (the block takes exactly one child table; the junction-order
1626
1819
  * stitch has no nested equivalent), and composite relation keys;
1627
1820
  * - a to-one relation carrying `limit`/`offset` (the loaders' semantics);
1821
+ * - a relation `limit` of exactly 0 (a nested block would emit `limit 0`,
1822
+ * which PowDB's projection fast path answered with ONE row below engine
1823
+ * 0.20; the loader path resolves it client-side and is correct on every
1824
+ * version). A NEGATIVE relation limit is refused outright, not fallen back;
1628
1825
  * - `distinct` inside the relation options (no nested grammar for it);
1629
1826
  * - a projected child column whose tsType is `bigint` or `Uint8Array`
1630
1827
  * (values ride a JSON array, which cannot carry them losslessly);
@@ -1650,6 +1847,9 @@ export class PowqlInterface {
1650
1847
  const single = rel.type === 'belongsTo' || rel.type === 'hasOne';
1651
1848
  if (single && (options.limit !== undefined || options.offset))
1652
1849
  return null;
1850
+ this.assertPagination(options.limit, options.offset, `relation "${relName}"`);
1851
+ if (options.limit === 0)
1852
+ return null;
1653
1853
  const targetQi = new PowqlInterface(this.pool, rel.to, this.schema, [], this.options);
1654
1854
  const cols = targetQi.projectedColumns(options.select, options.omit, includePii);
1655
1855
  const byName = new Map(targetQi.meta.columns.map((c) => [c.name, c]));
@@ -1984,6 +2184,52 @@ export class PowqlInterface {
1984
2184
  return row;
1985
2185
  });
1986
2186
  }
2187
+ /**
2188
+ * Refuse a `createMany` whose rows do not all name the SAME fields.
2189
+ *
2190
+ * PowQL could express it: a multi-row insert carries one `{ col := … }` tuple
2191
+ * per row, each with its own column list, so a ragged call inserts every
2192
+ * value. The SQL engines cannot, their single statement takes its column list
2193
+ * from the first row, so a field only a later row names is dropped and a field
2194
+ * only the first row names is written as NULL over that column's default. They
2195
+ * refuse it (ValidationError, query/writes.ts), and PowDB matching that
2196
+ * refusal is what makes the call portable: a shape accepted here and rejected
2197
+ * by every other engine turns a PowDB-to-Postgres move into a hard error found
2198
+ * in production rather than at the first run.
2199
+ *
2200
+ * Runs AFTER `applyPkDefault`, on the rows as they will actually be written:
2201
+ * a defaulted string PK is filled in on every row, so `[{}, { name }]` is
2202
+ * refused for the missing `name`, not for the PK the client supplied itself.
2203
+ * Rows that all name the same fields (the overwhelmingly common shape,
2204
+ * including N rows of pure defaults) cost one `Object.keys` pass and emit
2205
+ * byte-identical PowQL.
2206
+ */
2207
+ assertUniformCreateManyRows(rows) {
2208
+ const definedKeys = (row) => Object.keys(row).filter((k) => row[k] !== undefined);
2209
+ const firstKeys = definedKeys(rows[0]);
2210
+ const expected = new Set(firstKeys);
2211
+ const quoteList = (names) => names.map((n) => `"${n}"`).join(', ');
2212
+ for (let i = 1; i < rows.length; i++) {
2213
+ const rowKeys = definedKeys(rows[i]);
2214
+ // No stranger and the same count means the same set (object keys are unique).
2215
+ const unexpected = rowKeys.filter((k) => !expected.has(k));
2216
+ if (unexpected.length === 0 && rowKeys.length === expected.size)
2217
+ continue;
2218
+ const present = new Set(rowKeys);
2219
+ const missing = firstKeys.filter((k) => !present.has(k));
2220
+ const parts = [];
2221
+ if (missing.length > 0)
2222
+ parts.push(`does not supply ${quoteList(missing)}`);
2223
+ if (unexpected.length > 0)
2224
+ parts.push(`supplies ${quoteList(unexpected)}, which the first row does not`);
2225
+ throw new ValidationError(`[turbine] createMany on "${this.table}": row ${i} ${parts.join(' and ')}. ` +
2226
+ 'Every row must supply the same fields (a field set to `undefined` counts as omitted, exactly as it does ' +
2227
+ 'in `create`). PowQL itself would insert the ragged rows, but the SQL engines build ONE statement whose ' +
2228
+ "column list comes from the first row, so there a later row's extra field is dropped and a field it " +
2229
+ "omits is written as NULL over that column's default. Supply the field explicitly on every row, or " +
2230
+ 'split the call into one createMany per field set.');
2231
+ }
2232
+ }
1987
2233
  async createMany(args) {
1988
2234
  return this.withMiddleware('createMany', args, async () => {
1989
2235
  if (args.skipDuplicates) {
@@ -1994,6 +2240,7 @@ export class PowqlInterface {
1994
2240
  const inputs = args.data.map((d) => this.applyPkDefault(d));
1995
2241
  if (!inputs.length)
1996
2242
  return [];
2243
+ this.assertUniformCreateManyRows(inputs);
1997
2244
  const params = [];
1998
2245
  const tuples = inputs.map((d) => {
1999
2246
  const assigns = this.scalarData(d);
@@ -2250,6 +2497,34 @@ export class PowqlInterface {
2250
2497
  return Number((rows[0]?.value ?? rows[0]?.count ?? 0));
2251
2498
  });
2252
2499
  }
2500
+ /**
2501
+ * Gate ONE field of a per-field `_count`.
2502
+ *
2503
+ * `_count: { col: true }` compiles to `count(T { .col })`, which counts
2504
+ * NON-NULL values of the column (SQL's `COUNT(col)`) only from engine 0.20 on.
2505
+ * Below 0.20 both PowDB frontends ignored the projection and returned the ROW
2506
+ * count.
2507
+ *
2508
+ * That divergence only EXISTS on a nullable column: where the column is NOT
2509
+ * NULL the row count and the non-null count are the same number, so the
2510
+ * pre-0.20 answer was already the right one and the query keeps working. The
2511
+ * gate is therefore per-column, not per-call: refusing the whole feature would
2512
+ * take a correct, working call away from every user on the engine line Turbine
2513
+ * shipped against and hand them nothing.
2514
+ *
2515
+ * Nullability comes from the column metadata (`introspectPowdbDatabase` reads
2516
+ * PowDB's `required` modifier; `defineSchema` declares it). Metadata that
2517
+ * claims NOT NULL for a column the live catalog lets be null would count rows
2518
+ * instead of values below 0.20, the same drift any stale-metadata query has.
2519
+ */
2520
+ assertProjectedCountSupported(field) {
2521
+ const col = this.column(field);
2522
+ if (!col.nullable)
2523
+ return;
2524
+ requireCapability(this.capabilities, 'projectedCountNonNull', `per-field \`_count\` of the nullable column "${col.name}"`, 'Below that version the engine ignored the column projection and returned the ROW count, which differs from ' +
2525
+ 'every SQL engine exactly when the column is nullable. `_count: true` (a row count) and a per-field ' +
2526
+ '`_count` of a NOT NULL column are correct on every version and are never refused.');
2527
+ }
2253
2528
  async aggregate(args) {
2254
2529
  return this.withMiddleware('aggregate', args, async () => {
2255
2530
  // One scalar query per aggregate, PowDB's bare-projection aggregate is broken.
@@ -2271,6 +2546,7 @@ export class PowqlInterface {
2271
2546
  else {
2272
2547
  const counts = {};
2273
2548
  for (const field of Object.keys(args._count).filter((f) => args._count[f])) {
2549
+ this.assertProjectedCountSupported(field);
2274
2550
  counts[field] = (await scalar(`count(${this.qt}${filter} { ${this.ref(field)} })`)) ?? 0;
2275
2551
  }
2276
2552
  result._count = counts;
@@ -2440,7 +2716,11 @@ export class PowqlInterface {
2440
2716
  const having = this.buildHaving(args.having, params, aggInner);
2441
2717
  const order = this.buildGroupOrder(args.orderBy, byOrderExprs, aggOrderExprs);
2442
2718
  // LIMIT / OFFSET over the result groups, applied after ORDER BY (mirrors
2443
- // the SQL groupBy). offset 0 is a no-op, matching findMany.
2719
+ // the SQL groupBy). offset 0 is a no-op, matching findMany; `limit: 0` is
2720
+ // answered client-side (PowDB returned one row for it below engine 0.20).
2721
+ this.assertPagination(args.limit, args.offset, 'groupBy');
2722
+ if (args.limit === 0)
2723
+ return [];
2444
2724
  const limitClause = args.limit !== undefined ? ` limit ${this.param(args.limit, params)}` : '';
2445
2725
  const offsetClause = args.offset ? ` offset ${this.param(args.offset, params)}` : '';
2446
2726
  const powql = `${this.qt}${filter} group ${groupExprs.join(', ')}${having}${order}${limitClause}${offsetClause} { ${proj.join(', ')} }`;
@@ -25,7 +25,10 @@
25
25
  * , the un-awaited delegate calls defer to Turbine's `build*()` methods and
26
26
  * run atomically through the core batch `$transaction([...])` path.
27
27
  * - **Raw SQL**: `$queryRaw` / `$executeRaw` tagged templates (with
28
- * `Prisma.sql`-style nested-fragment flattening) and the `*Unsafe` variants.
28
+ * `Prisma.sql`-style nested-fragment flattening) and the `*Unsafe` variants,
29
+ * on the client AND on the transaction client, where they run on the
30
+ * transaction's own connection so a mixed raw + delegate `$transaction` stays
31
+ * atomic.
29
32
  * - **Result reshaping**: `_count` objects keyed back to Prisma relation names,
30
33
  * and to-one relations surfaced as `object | null`.
31
34
  *
@@ -128,6 +131,18 @@ export interface CompatQueryInterface {
128
131
  /** A transaction-scoped client handed to a `$transaction(callback)`. */
129
132
  export interface CompatTransactionClient {
130
133
  table(name: string): CompatQueryInterface;
134
+ /**
135
+ * Execute a prebuilt `(text, params)` statement on the TRANSACTION's own
136
+ * connection (core `TransactionClient.rawQuery`). It is what backs the
137
+ * transaction-scoped `$queryRaw` / `$executeRaw`; without it those methods
138
+ * would have to reach around the transaction to the pool, which silently
139
+ * breaks atomicity. Optional only so a test stub can omit it: when it is
140
+ * absent the raw methods throw instead of escaping the transaction.
141
+ */
142
+ rawQuery?(text: string, params?: readonly unknown[]): Promise<{
143
+ rows: unknown[];
144
+ rowCount: number | null;
145
+ }>;
131
146
  }
132
147
  /** The minimal `TurbineClient` surface the adapter consumes. */
133
148
  export interface CompatTurbineClient extends CompatTransactionClient {
@@ -135,6 +150,21 @@ export interface CompatTurbineClient extends CompatTransactionClient {
135
150
  $transaction<R>(fn: (tx: CompatTransactionClient) => Promise<R>, options?: unknown): Promise<R>;
136
151
  $transaction(queries: readonly DeferredQuery<unknown>[]): Promise<unknown[]>;
137
152
  }
153
+ /**
154
+ * Brand marking an object as a raw-SQL fragment whose `strings` are spliced
155
+ * VERBATIM into the emitted statement (see `flattenTemplate`). Deliberately a
156
+ * module-private `Symbol()` and NOT `Symbol.for(...)`: a registry symbol is
157
+ * reachable by name from anywhere in the process, so any dependency could mint
158
+ * an object that flattens as trusted SQL. With a private symbol the only way to
159
+ * obtain a fragment is to call `Prisma.sql` / `Prisma.join` / `Prisma.raw` from
160
+ * this module.
161
+ *
162
+ * The fragment check is fail-CLOSED: an object that does not carry this exact
163
+ * symbol is bound as a `$N` parameter, never spliced. That is also what makes
164
+ * the (contrived) dual-package case safe rather than dangerous, a fragment
165
+ * built by the ESM copy of this module and executed by the CJS copy binds as a
166
+ * parameter instead of composing.
167
+ */
138
168
  declare const SQL_FRAGMENT: unique symbol;
139
169
  /** A composable SQL fragment, the local stand-in for `Prisma.Sql`. */
140
170
  export interface Sql {
@@ -227,16 +257,24 @@ export interface PrismaModelDelegate<M extends PrismaModelTypes> {
227
257
  aggregate(args: Args): Promise<Record<string, unknown>>;
228
258
  groupBy(args: Args): Promise<Record<string, unknown>[]>;
229
259
  }
260
+ /**
261
+ * The four Prisma raw-SQL methods. Present on BOTH the client and the
262
+ * transaction-scoped client, exactly as in Prisma, so a migrated call site that
263
+ * mixes `$transaction` with raw SQL keeps working, and the raw statement runs on
264
+ * the transaction's own connection.
265
+ */
266
+ export interface PrismaCompatRawSurface {
267
+ $queryRaw<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
268
+ $queryRawUnsafe<T = unknown>(sql: string, ...params: unknown[]): Promise<T[]>;
269
+ $executeRaw(strings: TemplateStringsArray, ...values: unknown[]): Promise<number>;
270
+ $executeRawUnsafe(sql: string, ...params: unknown[]): Promise<number>;
271
+ }
230
272
  /** The client-level surface (`$transaction` / raw), added to the model map. */
231
- export interface PrismaCompatClientBase<S extends Record<string, PrismaModelTypes> = Record<string, PrismaModelTypes>> {
273
+ export interface PrismaCompatClientBase<S extends Record<string, PrismaModelTypes> = Record<string, PrismaModelTypes>> extends PrismaCompatRawSurface {
232
274
  $transaction<R>(fn: (tx: PrismaCompatTransactionClient<S>) => Promise<R>, options?: PrismaCompatTxOptions): Promise<R>;
233
275
  $transaction<P extends readonly PromiseLike<unknown>[]>(promises: readonly [...P]): Promise<{
234
276
  [K in keyof P]: Awaited<P[K]>;
235
277
  }>;
236
- $queryRaw<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
237
- $queryRawUnsafe<T = unknown>(sql: string, ...params: unknown[]): Promise<T[]>;
238
- $executeRaw(strings: TemplateStringsArray, ...values: unknown[]): Promise<number>;
239
- $executeRawUnsafe(sql: string, ...params: unknown[]): Promise<number>;
240
278
  $connect(): Promise<void>;
241
279
  $disconnect(): Promise<void>;
242
280
  }
@@ -246,10 +284,18 @@ export interface PrismaCompatTxOptions {
246
284
  timeout?: number;
247
285
  maxWait?: number;
248
286
  }
249
- /** The transaction-scoped client handed to a `$transaction(callback)`. */
287
+ /**
288
+ * The transaction-scoped client handed to a `$transaction(callback)`: a model
289
+ * delegate per Prisma model (under both spellings, as on the client) plus the
290
+ * raw-SQL surface, every one of them bound to the transaction's connection.
291
+ * Prisma's transaction client has no `$transaction` / `$connect` /
292
+ * `$disconnect`, and neither does this one.
293
+ */
250
294
  export type PrismaCompatTransactionClient<S extends Record<string, PrismaModelTypes> = Record<string, PrismaModelTypes>> = {
251
295
  [K in keyof S]: PrismaModelDelegate<S[K]>;
252
- };
296
+ } & {
297
+ [K in keyof S as Uncapitalize<K & string>]: PrismaModelDelegate<S[K]>;
298
+ } & PrismaCompatRawSurface;
253
299
  /**
254
300
  * The full typed compat client: a model delegate per Prisma model name, plus the
255
301
  * client-level `$transaction` / raw surface. Parameterize `S` with your