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
@@ -134,6 +134,82 @@ export const AUTO_TO_ONE_JOIN_MAX_ROWS = Math.round(AUTO_ASSUMED_ROUND_TRIP_MS /
134
134
  */
135
135
  export const AUTO_TO_ONE_JOIN_ROWS_MIN = 100;
136
136
  export const AUTO_TO_ONE_JOIN_ROWS_MAX = 100_000;
137
+ /**
138
+ * WHY THIS IS A CONFIGURED LATENCY AND NOT A MEASURED ONE.
139
+ *
140
+ * The obvious next step from the formula above is to have the client measure
141
+ * its own round-trip time and derive the threshold at runtime. That was built
142
+ * and benchmarked, and it is NOT what ships, for a reason worth recording so it
143
+ * is not re-litigated blind:
144
+ *
145
+ * Every query's wall time is `roundTrip + serverWork`, and nothing in a
146
+ * duration distinguishes the two. An all-time MINIMUM reads a lucky packet
147
+ * (1.489ms on a link whose real per-statement cost was 2.862ms) and lands the
148
+ * threshold at half the true break-even. A MEDIAN over recent durations is
149
+ * accurate when the workload is cheap queries, but the workload being planned
150
+ * for here is precisely the expensive one: in the verification sweep the ring
151
+ * filled with 10-17ms relation queries, the estimate inflated, and `'auto'`
152
+ * held an 8,000-row query on the join plan, 1.30x slower than the better plan,
153
+ * WORSE than the fixed constant it replaced. Capping the median against a
154
+ * multiple of the floor mitigates it but turns the whole thing into a pair of
155
+ * magic numbers tuned against two synthetic links, which is the same mistake as
156
+ * a socket-tuned row count wearing a different hat.
157
+ *
158
+ * Round-trip time is a deployment fact, not a runtime discovery: it is fixed by
159
+ * where the app runs relative to the database, the operator knows it (or gets
160
+ * it from one `ping`), and it does not change between queries. So it is
161
+ * configuration. That also keeps plan selection deterministic, which matters
162
+ * for a library whose documented guarantee is that the strategy changes the
163
+ * plan and never the result.
164
+ */
165
+ /**
166
+ * The smallest plan-time parent-row bound at which `'auto'` moves a relation
167
+ * `_count` on a PROVEN-UNINDEXED probe to the grouped follow-up. Deliberately
168
+ * 2, i.e. "everything except a parent set provably bounded at one row".
169
+ *
170
+ * `_count` does NOT share the to-one break-even formula above, because its two
171
+ * plans do not differ by a small per-row penalty. Writing S for one scan of the
172
+ * child table and RTT for a round trip:
173
+ *
174
+ * inline(N) = N x S (a correlated COUNT(*) per parent row; see
175
+ * buildRelationCountExpr in relations.ts, the
176
+ * inline form is NOT a grouped scan)
177
+ * batched(N) = S + RTT (one `COUNT(*) ... GROUP BY fk` follow-up)
178
+ *
179
+ * so the crossover sits at `N = 1 + RTT/S` and, decisively, the two regrets are
180
+ * not comparable in kind:
181
+ *
182
+ * - choosing batched when inline would have won costs at most RTT, once, and
183
+ * ONLY at N = 1 (at N = 1 the difference is exactly RTT, and it shrinks to
184
+ * zero immediately after);
185
+ * - choosing inline when batched would have won costs (N - 1) x S, which is
186
+ * unbounded in the parent count.
187
+ *
188
+ * Measured on an UNINDEXED FK (PostgreSQL 16, 200K-row child table, 10K-row
189
+ * parent table, median of 11 interleaved reps per point, loopback;
190
+ * benchmarks/bench-count-strategy.ts):
191
+ *
192
+ * parents 1 2 3 5 20 100 1000 10000
193
+ * inline 4.3ms 8.3ms 12.3ms 20.1ms 79.2ms 417.5ms 3.06s 31.06s
194
+ * batched 5.2ms 4.8ms 4.8ms 5.2ms 6.1ms 11.5ms 9.9ms 28.4ms
195
+ * winner inline batched batched batched batched batched batched batched
196
+ * ratio 1.22x 1.73x 2.54x 3.84x 13.05x 36.42x 310.92x 1093.35x
197
+ *
198
+ * Inline wins exactly one cell, by 0.9ms, then loses the next by 1.73x and the
199
+ * last by 1093x. A skewed child distribution (half the rows on ten parents)
200
+ * moves nothing: same crossover at 2, same 1179x at 10,000. So the useful
201
+ * threshold is not a tunable row count, it is the one row where inline provably
202
+ * cannot lose. There is deliberately no config knob: the entire regret this rule
203
+ * can produce is one round trip, which is less than any knob would be worth, and
204
+ * `relationLoadStrategy: 'join'` already forces the single-statement plan.
205
+ *
206
+ * This applies ONLY to a probe the introspected index metadata PROVES unindexed.
207
+ * An INDEXED `_count` stays inline at every size measured (inline wins 1.30x to
208
+ * 2.06x from 1 to 10,000 parents, because the per-parent subquery collapses to
209
+ * an index-only scan costing ~0.001ms), and the partition below never demotes
210
+ * it.
211
+ */
212
+ export const AUTO_COUNT_BATCH_MIN_PARENT_ROWS = 2;
137
213
  /**
138
214
  * Strict structural equality for a single SQL parameter value. Handles the
139
215
  * value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
@@ -436,15 +512,41 @@ export class QueryInterface {
436
512
  this.txScoped = options?._txScoped ?? false;
437
513
  this.options = options;
438
514
  // Pre-compute column type lookup maps (TASK-26)
515
+ //
516
+ // Metadata can carry a column's database type in EITHER of two places: on
517
+ // the column entry (`dialectType` / `pgType`, what introspection and
518
+ // `turbine generate` emit) or in the table-level `dialectTypes` / `pgTypes`
519
+ // maps. Reading only the column entry is not a harmless miss for metadata
520
+ // that populates just the table-level maps: an unresolved type makes
521
+ // `coerceWriteValue` return a bound `Date` by identity, so a zone-less
522
+ // `date` / `timestamp` column stores the PROCESS's local calendar fields
523
+ // (and, because the read path pins UTC, a turbine-only round trip hides
524
+ // it). Consult both, column entry first. This costs one extra own-property
525
+ // lookup per column ONCE per QueryInterface, nothing per query.
439
526
  this.columnPgTypeMap = new Map();
440
527
  this.columnArrayTypeMap = new Map();
441
528
  this.crossSchemaTypeColumns = new Set();
529
+ const tableDialectTypes = this.tableMeta.dialectTypes;
530
+ const tablePgTypes = this.tableMeta.pgTypes;
442
531
  for (const col of this.tableMeta.columns) {
443
- this.columnPgTypeMap.set(col.name, col.dialectType ?? col.pgType);
444
- this.columnArrayTypeMap.set(col.name, col.arrayType ?? col.pgArrayType);
532
+ const dbType = col.dialectType ??
533
+ col.pgType ??
534
+ (tableDialectTypes && ownLookup(tableDialectTypes, col.name)) ??
535
+ (tablePgTypes && ownLookup(tablePgTypes, col.name));
536
+ if (dbType !== undefined)
537
+ this.columnPgTypeMap.set(col.name, dbType);
538
+ // The array map has the same shape of gap, but TableMetadata carries NO
539
+ // table-level array-type map (only `dialectTypes` / `pgTypes`), so there
540
+ // is nothing to fall back to. Deriving one from the resolved base type
541
+ // would invent an UNNEST cast the metadata never declared, so the column
542
+ // entry stays the only source.
543
+ const arrayType = col.arrayType ?? col.pgArrayType;
544
+ if (arrayType !== undefined)
545
+ this.columnArrayTypeMap.set(col.name, arrayType);
445
546
  if (col.pgTypeSchema !== undefined)
446
547
  this.crossSchemaTypeColumns.add(col.name);
447
548
  }
549
+ this.warnUntypedColumns();
448
550
  // Bind the shared WHERE-walk view once. `tableMeta` is immutable after this
449
551
  // point; the method wrappers forward to the (private) instance methods so
450
552
  // the walk needs no public accessors on the class.
@@ -468,6 +570,18 @@ export class QueryInterface {
468
570
  scopedHostCache: this.scopedHostCache,
469
571
  columnPgTypeMap: this.columnPgTypeMap,
470
572
  columnArrayTypeMap: this.columnArrayTypeMap,
573
+ // The `utcTimestamps: false` opt-out has to reach the write/where web:
574
+ // omitting it here left `qi.utcTimestamps` undefined for the whole
575
+ // module, where `!== false` reads as opted IN, so the write side ignored
576
+ // the flag and rewrote binds the caller had opted out of.
577
+ //
578
+ // This half of the flag is PER CLIENT. The read half is not: it is the
579
+ // pg OID 1114 type parser, which `pg.types.setTypeParser` installs once
580
+ // per process. Two clients in one process therefore cannot hold
581
+ // different values, and TurbineClient refuses the second one rather than
582
+ // building a client whose writes and reads disagree (see
583
+ // `assertUtcTimestampsAgree` in client.ts).
584
+ utcTimestamps: this.utcTimestamps,
471
585
  crossSchemaTypeColumns: this.crossSchemaTypeColumns,
472
586
  get currentSkip() {
473
587
  return self.currentSkip;
@@ -498,6 +612,73 @@ export class QueryInterface {
498
612
  paginationValue: (value, arg) => this.paginationValue(value, arg),
499
613
  };
500
614
  }
615
+ /**
616
+ * Dev-only, once per table: the columns whose database type is absent from
617
+ * BOTH the column entry and the table-level type maps, the residual case
618
+ * after the two-source resolution above.
619
+ *
620
+ * The set is deliberately every untyped column, not the `dateColumns`
621
+ * members. An unresolved type is precisely the state in which Turbine cannot
622
+ * say WHICH kind of column it is, so restricting the scan to `dateColumns`
623
+ * got it wrong in both directions: that set carries `timestamptz` (whose
624
+ * bind was never affected, since binding the `Date` is the correct thing to
625
+ * do for it) and omits `time` / `timetz` entirely (deliberately, see
626
+ * `timeOfDayKind` in schema.ts), which is the one kind that fails LOUDLY
627
+ * rather than silently. The message therefore names the columns and states
628
+ * what each kind does, rather than asserting a kind it cannot know.
629
+ *
630
+ * What is actually at stake per kind, all of it `coerceWriteValue` returning
631
+ * the bound `Date` by identity for want of a type:
632
+ * - zone-less `date` / `timestamp`: the driver serializes with the
633
+ * PROCESS's offset, so the column stores local calendar fields. Nothing
634
+ * surfaces at runtime, and a turbine-only round trip reads the same value
635
+ * back (the read path shifts by the same offset), so only an outside
636
+ * reader sees the drift.
637
+ * - `time` / `timetz`: the driver serializes a full ISO timestamp, which
638
+ * Postgres rejects with `22007 invalid input syntax for type time`.
639
+ * - `timestamptz` and every non-temporal type: unaffected.
640
+ *
641
+ * PostgreSQL only: the UTC bind rewrite is Postgres-gated (see
642
+ * `utcDateTimeWrites` in writes.ts), so on the other engines a missing type
643
+ * changes nothing about how a `Date` is bound. Suppressed under
644
+ * `NODE_ENV=production` like the other dev diagnostics and deduped through
645
+ * the shared registry, so a hot table logs one line for the process.
646
+ *
647
+ * Cannot throw on odd metadata: it walks `tableMeta.columns`, the array the
648
+ * constructor loop above has already iterated (and that client.ts validates
649
+ * as an array), never `dateColumns`, which is a `Set` in every first-party
650
+ * metadata path but arrives as a plain object from JSON-round-tripped
651
+ * metadata. A dev-only diagnostic that crashes a shape production would serve
652
+ * is worse than the bug it reports.
653
+ */
654
+ warnUntypedColumns() {
655
+ if (process.env.NODE_ENV === 'production')
656
+ return;
657
+ if (this.dialect.name !== 'postgresql')
658
+ return;
659
+ const untyped = [];
660
+ for (const col of this.tableMeta.columns) {
661
+ if (this.columnPgTypeMap.get(col.name) === undefined)
662
+ untyped.push(col.name);
663
+ }
664
+ if (untyped.length === 0)
665
+ return;
666
+ if (!shouldWarnOnce(WARN_NS.untypedDateColumn, this.table))
667
+ return;
668
+ // Bound the line on a wide table: the fix is per table, not per column, so
669
+ // the first few names are enough to recognize the metadata that produced it.
670
+ const MAX_NAMED = 12;
671
+ const named = untyped.slice(0, MAX_NAMED).join(', ');
672
+ const rest = untyped.length > MAX_NAMED ? ` (+${untyped.length - MAX_NAMED} more)` : '';
673
+ console.warn(`[turbine] table "${this.table}": no database type in metadata for column(s) ${named}${rest} (neither the ` +
674
+ "column entry's `dialectType`/`pgType` nor the table-level `dialectTypes`/`pgTypes` map). Turbine cannot " +
675
+ 'tell which of them are zone-less, so a `Date` written to one is bound by the driver as-is: right for ' +
676
+ "`timestamptz`, but a zone-less `date`/`timestamp` column then stores the PROCESS's local calendar fields " +
677
+ 'rather than UTC (silently, since the read path shifts back by the same offset), and a `time`/`timetz` ' +
678
+ 'column rejects the value outright (`22007 invalid input syntax for type time`). Columns whose type IS ' +
679
+ 'resolved, every `timestamptz` among them, are unaffected. Regenerate the metadata with ' +
680
+ '`npx turbine generate`, or set the column types in your `defineSchema` definition.');
681
+ }
501
682
  /** Quote an identifier through the active SQL dialect. */
502
683
  q(name) {
503
684
  return this.dialect.quoteIdentifier(name);
@@ -942,8 +1123,20 @@ export class QueryInterface {
942
1123
  * `findFirst` pass `false` explicitly (their parent set is one row).
943
1124
  */
944
1125
  autoParentSetLarge(args) {
945
- const limit = args?.take ?? args?.limit ?? this.defaultLimit;
946
- return limit === undefined || limit > this.autoToOneThreshold();
1126
+ const bound = this.autoParentBound(args);
1127
+ return bound === undefined || bound > this.autoToOneThreshold();
1128
+ }
1129
+ /**
1130
+ * The plan-time UPPER BOUND on the parent-row count, or `undefined` when the
1131
+ * query is unbounded. This is the raw number behind
1132
+ * {@link autoParentSetLarge}; the `_count` rule needs the number itself
1133
+ * because its threshold ({@link AUTO_COUNT_BATCH_MIN_PARENT_ROWS}) is two
1134
+ * rows rather than the to-one break-even. `findUnique` / `findFirst` pass `1`
1135
+ * directly: their parent set is one row as a matter of the statement's shape,
1136
+ * not an estimate.
1137
+ */
1138
+ autoParentBound(args) {
1139
+ return args?.take ?? args?.limit ?? this.defaultLimit;
947
1140
  }
948
1141
  /**
949
1142
  * The parent-row count at which `'auto'` stops preferring the single-statement
@@ -990,13 +1183,13 @@ export class QueryInterface {
990
1183
  *
991
1184
  * Everything else (indexed to-many, composite-key, unknown) stays in `joinWith`
992
1185
  * (byte-identical join). The reserved `_count` key falls back on rule 1 only,
993
- * and only for a large parent set: an inline `_count` is one correlated
994
- * `COUNT(*)` per parent row, so the grouped follow-up wins exactly when there
995
- * are many parents, while for a handful of parents the extra round-trip costs
996
- * more than the repeated (small) scans. Also returns the engaged relations for
997
- * the dev note.
1186
+ * and on its OWN size rule: an inline `_count` is one correlated `COUNT(*)`
1187
+ * per parent row over an unindexed child table, so the grouped follow-up wins
1188
+ * from {@link AUTO_COUNT_BATCH_MIN_PARENT_ROWS} parent rows upward and inline
1189
+ * is preferred only when the parent set is provably bounded below that. Also
1190
+ * returns the engaged relations for the dev note.
998
1191
  */
999
- partitionWithForAuto(withClause, parentSetLarge) {
1192
+ partitionWithForAuto(withClause, parentSetLarge, parentBound) {
1000
1193
  const hasIndexInfo = schemaHasIndexInfo(this.schema);
1001
1194
  const joinWith = {};
1002
1195
  const batchedWith = {};
@@ -1006,7 +1199,8 @@ export class QueryInterface {
1006
1199
  continue;
1007
1200
  if (key === '_count') {
1008
1201
  const cv = this.autoCountVerdict(spec, this.tableMeta);
1009
- if (hasIndexInfo && parentSetLarge && cv.unindexed && cv.eligible) {
1202
+ const countWorthBatching = parentBound === undefined || parentBound >= AUTO_COUNT_BATCH_MIN_PARENT_ROWS;
1203
+ if (hasIndexInfo && countWorthBatching && cv.unindexed && cv.eligible) {
1010
1204
  batchedWith[key] = spec;
1011
1205
  engaged.push({ relation: '_count', reason: 'unindexed', miss: cv.miss });
1012
1206
  }
@@ -1044,7 +1238,7 @@ export class QueryInterface {
1044
1238
  * to batched. Returns `null` (→ run the plain join path, byte-identical, same
1045
1239
  * cache keys) when nothing qualifies.
1046
1240
  */
1047
- planAuto(withArg, stableFlag, parentSetLarge) {
1241
+ planAuto(withArg, stableFlag, parentSetLarge, parentBound) {
1048
1242
  // Without DB-backed index info (code-first / defineSchema-only) no probe can
1049
1243
  // be PROVEN unindexed; the to-one cardinality rule does not depend on index
1050
1244
  // metadata, so it still applies.
@@ -1053,7 +1247,7 @@ export class QueryInterface {
1053
1247
  const withClause = this.resolveStableOrder(stableFlag)
1054
1248
  ? this.applyStableRelationOrder(withArg, this.table)
1055
1249
  : withArg;
1056
- const split = this.partitionWithForAuto(withClause, parentSetLarge);
1250
+ const split = this.partitionWithForAuto(withClause, parentSetLarge, parentBound);
1057
1251
  if (Object.keys(split.batchedWith).length === 0)
1058
1252
  return null;
1059
1253
  return split;
@@ -1075,7 +1269,15 @@ export class QueryInterface {
1075
1269
  const probe = e.miss
1076
1270
  ? `probe "${e.miss.table}"(${e.miss.columns.join(', ')}) has no covering index`
1077
1271
  : 'a probe in its subtree has no covering index';
1078
- console.warn(`[turbine] auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}). ` +
1272
+ // The `_count` case is the one people read as a needless demotion, because
1273
+ // the follow-up statement is a grouped COUNT and the inline form looks
1274
+ // like it would be one too. It is not: state the shape it replaced.
1275
+ const why = e.relation === '_count'
1276
+ ? ' The inline form is one correlated COUNT(*) re-evaluated per parent row, so on an unindexed ' +
1277
+ `probe it is one full scan of "${e.miss?.table ?? 'the child table'}" per parent row; the ` +
1278
+ 'follow-up is one grouped scan for the whole page.'
1279
+ : '';
1280
+ console.warn(`[turbine] auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}).${why} ` +
1079
1281
  "Create the covering index (or set `relationLoadStrategy: 'join'` to force the single-statement " +
1080
1282
  'plan); run `npx turbine doctor` for the exact CREATE INDEX SQL.');
1081
1283
  }
@@ -1491,8 +1693,10 @@ export class QueryInterface {
1491
1693
  return this.runFindUniqueBatched(args);
1492
1694
  if (strategy === 'auto') {
1493
1695
  // findUnique's parent set is a single row: the join plan's correlated
1494
- // subqueries run once, so the cardinality rule never applies here.
1495
- const split = this.planAuto(args.with, args.stableRelationOrder, false);
1696
+ // subqueries run once, so the cardinality rule never applies here, and
1697
+ // an inline `_count` is one scan against the batched plan's one scan
1698
+ // plus a round trip.
1699
+ const split = this.planAuto(args.with, args.stableRelationOrder, false, 1);
1496
1700
  if (split)
1497
1701
  return this.runAutoSplit(args, split, true);
1498
1702
  }
@@ -1676,7 +1880,7 @@ export class QueryInterface {
1676
1880
  if (strategy === 'batched')
1677
1881
  return this.runFindManyBatched(args);
1678
1882
  if (strategy === 'auto') {
1679
- const split = this.planAuto(args.with, args.stableRelationOrder, this.autoParentSetLarge(args));
1883
+ const split = this.planAuto(args.with, args.stableRelationOrder, this.autoParentSetLarge(args), this.autoParentBound(args));
1680
1884
  if (split)
1681
1885
  return this.runAutoSplit(args, split, false);
1682
1886
  }
@@ -2185,7 +2389,7 @@ export class QueryInterface {
2185
2389
  }
2186
2390
  if (strategy === 'auto') {
2187
2391
  // findFirst is findMany + LIMIT 1: a one-row parent set.
2188
- const split = this.planAuto(args.with, args.stableRelationOrder, false);
2392
+ const split = this.planAuto(args.with, args.stableRelationOrder, false, 1);
2189
2393
  if (split) {
2190
2394
  const rows = (await this.runAutoSplit({ ...args, limit: 1 }, split, false));
2191
2395
  return (rows[0] ?? null);
Binary file
@@ -11,4 +11,4 @@ export { postgresDialect } from '../dialect.js';
11
11
  export type { SqlCacheEntry } from './utils.js';
12
12
  export { buildCorrelation, escapeLike, escSingleQuote, fnv1a64Hex, LRUCache, OPERATOR_KEYS, quoteIdent, sqlToPreparedName, } from './utils.js';
13
13
  export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, } from './builder.js';
14
- export { AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, QueryInterface, } from './builder.js';
14
+ export { AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, QueryInterface, } from './builder.js';
@@ -7,4 +7,4 @@
7
7
  */
8
8
  export { postgresDialect } from '../dialect.js';
9
9
  export { buildCorrelation, escapeLike, escSingleQuote, fnv1a64Hex, LRUCache, OPERATOR_KEYS, quoteIdent, sqlToPreparedName, } from './utils.js';
10
- export { AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, QueryInterface, } from './builder.js';
10
+ export { AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, QueryInterface, } from './builder.js';
@@ -65,4 +65,14 @@ export declare const WARN_NS: {
65
65
  * engage is indistinguishable from one that does nothing, so say so once.
66
66
  */
67
67
  readonly flattenFallback: "flattenFallback";
68
+ /**
69
+ * Columns whose database type resolves from NEITHER the column entry nor the
70
+ * table-level type maps, so Turbine cannot tell which of them are zone-less
71
+ * and a bound `Date` is left to the driver rather than rewritten to a UTC
72
+ * literal (builder.ts `warnUntypedColumns`). The scan covers every column,
73
+ * not just `dateColumns`, because an unresolved type is precisely the state
74
+ * in which the kind is unknown; the warning emits one line per table listing
75
+ * the offenders, not one per column. The namespace name is historical.
76
+ */
77
+ readonly untypedDateColumn: "untypedDateColumn";
68
78
  };
@@ -100,4 +100,14 @@ export const WARN_NS = {
100
100
  * engage is indistinguishable from one that does nothing, so say so once.
101
101
  */
102
102
  flattenFallback: 'flattenFallback',
103
+ /**
104
+ * Columns whose database type resolves from NEITHER the column entry nor the
105
+ * table-level type maps, so Turbine cannot tell which of them are zone-less
106
+ * and a bound `Date` is left to the driver rather than rewritten to a UTC
107
+ * literal (builder.ts `warnUntypedColumns`). The scan covers every column,
108
+ * not just `dateColumns`, because an unresolved type is precisely the state
109
+ * in which the kind is unknown; the warning emits one line per table listing
110
+ * the offenders, not one per column. The namespace name is historical.
111
+ */
112
+ untypedDateColumn: 'untypedDateColumn',
103
113
  };
@@ -10,7 +10,7 @@
10
10
  * stay class-resident, reached through the ctx. See builder.ts for the thin
11
11
  * delegating methods and the async execute wrappers.
12
12
  */
13
- import { NotFoundError, OptimisticLockError, ValidationError } from '../errors.js';
13
+ import { NotFoundError, OptimisticLockError, UnsupportedFeatureError, ValidationError } from '../errors.js';
14
14
  import { camelToSnake, snakeToCamel } from '../schema.js';
15
15
  import { expandCompoundUniqueWhere } from './compound-unique.js';
16
16
  import { isUnmatchedPlainObject, UPDATE_OPERATOR_KEYS } from './filters.js';
@@ -77,6 +77,36 @@ export function buildReselectByWhere(qi, whereObj) {
77
77
  const where = clause ? ` WHERE ${clause}` : '';
78
78
  return { sql: `SELECT ${writeReselectSelection(qi)} FROM ${qi.q(qi.table)}${where}`, params };
79
79
  }
80
+ /**
81
+ * Build the all-defaults INSERT for a `data` that names no column, via the
82
+ * dialect's `buildDefaultValuesInsertStatement` hook.
83
+ *
84
+ * `INSERT INTO t () VALUES ()` (what the column-driven builders render for an
85
+ * empty `data`) is a syntax error everywhere but MySQL, and the correct
86
+ * statement differs per engine, so the shape lives behind the dialect seam.
87
+ * `{}` is easy to reach honestly, a handler that assembles its payload from
88
+ * optional request fields produces it on a request that supplied none of them,
89
+ * so the row of pure defaults is inserted and returned rather than crashing.
90
+ * The empty-`update` no-op in {@link buildUpdate} has the same rationale.
91
+ *
92
+ * A table with no usable defaults still fails, correctly, with the database's
93
+ * own NOT NULL violation (E010): nothing here pre-empts that.
94
+ *
95
+ * A dialect predating the hook raises E017 rather than emitting SQL its engine
96
+ * will reject.
97
+ */
98
+ function buildDefaultValuesInsert(qi, rowCount, skipDuplicates) {
99
+ const build = qi.dialect.buildDefaultValuesInsertStatement;
100
+ if (!build) {
101
+ throw new UnsupportedFeatureError('create/createMany with an empty data object', qi.dialect.name, 'This dialect has no all-defaults INSERT form; name at least one column in `data`.');
102
+ }
103
+ return build.call(qi.dialect, {
104
+ table: qi.q(qi.table),
105
+ rowCount,
106
+ skipDuplicates,
107
+ returning: writeReturningColumns(qi),
108
+ });
109
+ }
80
110
  export function buildCreate(qi, args) {
81
111
  assertWritable(qi, 'create');
82
112
  assertNoGeneratedColumns(qi, args.data, 'create');
@@ -85,12 +115,15 @@ export function buildCreate(qi, args) {
85
115
  const params = entries.map(([k, v]) => coerceWriteValue(qi, k, v));
86
116
  // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
87
117
  const placeholders = entries.map(([k], i) => `${qi.p(i + 1)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`);
88
- const sql = qi.dialect.buildInsertStatement({
89
- table: qi.q(qi.table),
90
- columns,
91
- valuePlaceholders: placeholders,
92
- returning: writeReturningColumns(qi),
93
- });
118
+ // `data: {}` (or all-undefined) names no column: insert a row of defaults.
119
+ const sql = entries.length === 0
120
+ ? buildDefaultValuesInsert(qi, 1)
121
+ : qi.dialect.buildInsertStatement({
122
+ table: qi.q(qi.table),
123
+ columns,
124
+ valuePlaceholders: placeholders,
125
+ returning: writeReturningColumns(qi),
126
+ });
94
127
  return {
95
128
  sql,
96
129
  params,
@@ -137,6 +170,69 @@ export function makeCreateReselect(qi, insertSql, insertParams, data) {
137
170
  return exec(`SELECT ${writeReselectSelection(qi)} FROM ${qi.q(qi.table)}${where}`, selParams);
138
171
  };
139
172
  }
173
+ /**
174
+ * The fields a write's `data` object actually names.
175
+ *
176
+ * A key whose value is `undefined` is NOT named: single-row {@link buildCreate}
177
+ * filters those out of its column list, so the column takes its declared
178
+ * default. `createMany` reads its rows through this same helper, so
179
+ * `{ n: undefined }` and `{}` mean the identical thing on both paths.
180
+ */
181
+ function definedKeys(row) {
182
+ return Object.keys(row).filter((k) => row[k] !== undefined);
183
+ }
184
+ /**
185
+ * Refuse a `createMany` whose rows do not all name the SAME fields.
186
+ *
187
+ * `createMany` compiles ONE statement, and its column list is taken from the
188
+ * first row alone, so a row that disagrees with the first row loses data in
189
+ * both directions and silently:
190
+ *
191
+ * - a field the first row names and a later row OMITS is bound as NULL,
192
+ * overwriting that column's declared default (and failing outright on a NOT
193
+ * NULL column that has one);
194
+ * - a field only a LATER row names is not in the column list at all, so its
195
+ * value never reaches the database.
196
+ *
197
+ * Neither shape is expressible as a single statement on every engine. The
198
+ * row-major `VALUES` form can carry a per-cell `DEFAULT` keyword on
199
+ * PostgreSQL, MySQL and SQL Server, but SQLite has no such grammar (`VALUES
200
+ * (1, DEFAULT)` is a parse error there), and adopting it would also drop
201
+ * PostgreSQL off the column-major `UNNEST` form that binds one array per
202
+ * column rather than one placeholder per cell. So the honest answer is a
203
+ * refusal that names the offending row and its differing columns.
204
+ *
205
+ * A single row is trivially uniform, and rows that all name the same fields
206
+ * (the overwhelmingly common shape) cost one `Object.keys` pass and emit
207
+ * byte-identical SQL.
208
+ */
209
+ function assertUniformCreateManyRows(qi, rows, firstKeys) {
210
+ const expected = new Set(firstKeys);
211
+ for (let i = 1; i < rows.length; i++) {
212
+ const rowKeys = definedKeys(rows[i]);
213
+ const unexpected = rowKeys.filter((k) => !expected.has(k));
214
+ // No stranger and the same count means the same set (object keys are unique).
215
+ if (unexpected.length === 0 && rowKeys.length === expected.size)
216
+ continue;
217
+ const present = new Set(rowKeys);
218
+ const missing = firstKeys.filter((k) => !present.has(k));
219
+ const parts = [];
220
+ if (missing.length > 0)
221
+ parts.push(`does not supply ${quoteList(missing)}`);
222
+ if (unexpected.length > 0)
223
+ parts.push(`supplies ${quoteList(unexpected)}, which the first row does not`);
224
+ throw new ValidationError(`[turbine] createMany on "${qi.table}": row ${i} ${parts.join(' and ')}. ` +
225
+ 'Every row must supply the same fields: createMany builds ONE statement whose column list comes from the ' +
226
+ "first row, so a field a later row omits would be written as NULL over that column's default, and a field " +
227
+ 'only a later row names would be dropped. Supply the field explicitly on every row (a field set to ' +
228
+ '`undefined` counts as omitted, exactly as it does in `create`), or split the call into one createMany per ' +
229
+ 'row shape.');
230
+ }
231
+ }
232
+ /** `a, b` → `"a", "b"`, for the field lists in {@link assertUniformCreateManyRows}. */
233
+ function quoteList(fields) {
234
+ return fields.map((f) => `"${f}"`).join(', ');
235
+ }
140
236
  export function buildCreateMany(qi, args) {
141
237
  const qt = qi.q(qi.table);
142
238
  if (args.data.length === 0) {
@@ -151,7 +247,19 @@ export function buildCreateMany(qi, args) {
151
247
  for (const row of args.data) {
152
248
  assertNoGeneratedColumns(qi, row, 'createMany');
153
249
  }
154
- const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
250
+ const keys = definedKeys(args.data[0]);
251
+ assertUniformCreateManyRows(qi, args.data, keys);
252
+ // No column named by the first row: every row is pure defaults (the bulk
253
+ // counterpart of `create({ data: {} })`, see buildDefaultValuesInsert). The
254
+ // uniformity check above has already established that EVERY row is empty.
255
+ if (keys.length === 0) {
256
+ return {
257
+ sql: buildDefaultValuesInsert(qi, args.data.length, args.skipDuplicates),
258
+ params: [],
259
+ transform: (result) => result.rows.map((row) => parseWriteRow(qi, row)),
260
+ tag: `${qi.table}.createMany`,
261
+ };
262
+ }
155
263
  const columns = keys.map((k) => qi.toColumn(k));
156
264
  const rowValues = args.data.map((row) => {
157
265
  const record = row;
package/dist/sqlite.d.ts CHANGED
@@ -46,8 +46,9 @@
46
46
  * ```
47
47
  */
48
48
  import type { DatabaseSync } from 'node:sqlite';
49
- import { type PgCompatPool, type PgCompatPoolClient, TurbineClient, type TurbineConfig } from './client.js';
49
+ import { type PgCompatPool, type PgCompatPoolClient, TurbineClient } from './client.js';
50
50
  import { type Dialect, type IntrospectOptions } from './dialect.js';
51
+ import type { EngineClientConfig } from './engine-config.js';
51
52
  import { type SchemaMetadata, type TableMetadata } from './schema.js';
52
53
  /** pg-style query argument: a SQL string or a `{ text, values }` config object. */
53
54
  type QueryArg = string | {
@@ -109,8 +110,12 @@ export declare function introspectSqliteDatabase(db: DatabaseSync, options?: {
109
110
  * are per-handle), so codegen should target a real file.
110
111
  */
111
112
  export declare function introspectSqlite(options: IntrospectOptions): Promise<SchemaMetadata>;
112
- /** Options for {@link turbineSqlite}. Mirrors the relevant {@link TurbineConfig} fields. */
113
- export interface TurbineSqliteOptions extends Pick<TurbineConfig, 'logging' | 'defaultLimit' | 'warnOnUnlimited'> {
113
+ /**
114
+ * Options for {@link turbineSqlite}: every client-level {@link TurbineConfig}
115
+ * field the engine can honour (see {@link EngineClientConfig}) plus the SQLite
116
+ * pragmas below.
117
+ */
118
+ export interface TurbineSqliteOptions extends EngineClientConfig {
114
119
  /**
115
120
  * Enable WAL journal mode for file databases (better read concurrency).
116
121
  * Ignored for `':memory:'`. Default: `true`.
@@ -131,7 +136,8 @@ export interface TurbineSqliteOptions extends Pick<TurbineConfig, 'logging' | 'd
131
136
  *
132
137
  * @param target A SQLite file path, `':memory:'`, or an open `DatabaseSync`.
133
138
  * @param schema Introspected or hand-written {@link SchemaMetadata}.
134
- * @param options Optional pragmas + logging / defaultLimit / warnOnUnlimited.
139
+ * @param options Optional SQLite pragmas plus any client-level
140
+ * {@link TurbineConfig} field (see {@link EngineClientConfig}).
135
141
  *
136
142
  * @example
137
143
  * ```ts
package/dist/sqlite.js CHANGED
@@ -48,7 +48,7 @@
48
48
  import { createRequire } from 'node:module';
49
49
  import { TurbineClient } from './client.js';
50
50
  import { postgresDialect, } from './dialect.js';
51
- import { ConnectionError } from './errors.js';
51
+ import { ConnectionError, UnsupportedFeatureError } from './errors.js';
52
52
  import { applyTableFilters, deriveEngineRelations } from './introspect.js';
53
53
  import { isDateType, snakeToCamel, } from './schema.js';
54
54
  let cachedDatabaseSync;
@@ -482,6 +482,17 @@ export const sqliteDialect = {
482
482
  params: input.rowValues.flat(),
483
483
  };
484
484
  },
485
+ buildDefaultValuesInsertStatement(input) {
486
+ // SQLite's `DEFAULT VALUES` inserts exactly one row and the engine accepts
487
+ // neither `VALUES (DEFAULT, …)` nor MySQL's empty `VALUES ()` tuple, so
488
+ // there is no multi-row all-defaults statement to emit.
489
+ if (input.rowCount !== 1) {
490
+ throw new UnsupportedFeatureError(`createMany with ${input.rowCount} empty data rows`, 'sqlite', 'SQLite INSERT … DEFAULT VALUES inserts a single row and has no multi-row form; ' +
491
+ 'issue one create({ data: {} }) per row.');
492
+ }
493
+ const conflict = input.skipDuplicates ? ' ON CONFLICT DO NOTHING' : '';
494
+ return `INSERT INTO ${input.table} DEFAULT VALUES${conflict}${this.buildReturningClause(input.returning)}`;
495
+ },
485
496
  buildUpsertStatement(input) {
486
497
  return (`INSERT INTO ${input.table} (${input.insertColumns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})` +
487
498
  ` ON CONFLICT (${input.conflictColumns.join(', ')}) DO UPDATE SET ${input.updateSetClauses.join(', ')}` +
@@ -778,7 +789,8 @@ function openSqliteDatabase(target, options) {
778
789
  *
779
790
  * @param target A SQLite file path, `':memory:'`, or an open `DatabaseSync`.
780
791
  * @param schema Introspected or hand-written {@link SchemaMetadata}.
781
- * @param options Optional pragmas + logging / defaultLimit / warnOnUnlimited.
792
+ * @param options Optional SQLite pragmas plus any client-level
793
+ * {@link TurbineConfig} field (see {@link EngineClientConfig}).
782
794
  *
783
795
  * @example
784
796
  * ```ts
@@ -789,12 +801,14 @@ function openSqliteDatabase(target, options) {
789
801
  export function turbineSqlite(target, schema, options = {}) {
790
802
  const db = typeof target === 'string' ? openSqliteDatabase(target, options) : target;
791
803
  const pool = new SqlitePool(db);
804
+ // Everything that is not a SQLite pragma is client config and rides through
805
+ // untouched, so a new TurbineConfig option works here the day it lands. The
806
+ // engine-owned keys come last, so no caller can unbind the dialect.
807
+ const { wal: _wal, busyTimeoutMs: _busyTimeoutMs, foreignKeys: _foreignKeys, ...clientConfig } = options;
792
808
  return new TurbineClient({
809
+ ...clientConfig,
793
810
  pool,
794
811
  dialect: sqliteDialect,
795
812
  preparedStatements: false,
796
- logging: options.logging,
797
- defaultLimit: options.defaultLimit,
798
- warnOnUnlimited: options.warnOnUnlimited,
799
813
  }, schema);
800
814
  }