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
@@ -45,7 +45,7 @@ var __importStar = (this && this.__importStar) || (function () {
45
45
  };
46
46
  })();
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
- exports.QueryInterface = exports.AUTO_TO_ONE_JOIN_ROWS_MAX = exports.AUTO_TO_ONE_JOIN_ROWS_MIN = exports.AUTO_TO_ONE_JOIN_MAX_ROWS = exports.AUTO_ASSUMED_ROUND_TRIP_MS = exports.AUTO_JOIN_PENALTY_MS_PER_ROW = void 0;
48
+ exports.QueryInterface = exports.AUTO_COUNT_BATCH_MIN_PARENT_ROWS = exports.AUTO_TO_ONE_JOIN_ROWS_MAX = exports.AUTO_TO_ONE_JOIN_ROWS_MIN = exports.AUTO_TO_ONE_JOIN_MAX_ROWS = exports.AUTO_ASSUMED_ROUND_TRIP_MS = exports.AUTO_JOIN_PENALTY_MS_PER_ROW = void 0;
49
49
  const dialect_js_1 = require("../dialect.js");
50
50
  const errors_js_1 = require("../errors.js");
51
51
  const index_advisor_js_1 = require("../index-advisor.js");
@@ -170,6 +170,82 @@ exports.AUTO_TO_ONE_JOIN_MAX_ROWS = Math.round(exports.AUTO_ASSUMED_ROUND_TRIP_M
170
170
  */
171
171
  exports.AUTO_TO_ONE_JOIN_ROWS_MIN = 100;
172
172
  exports.AUTO_TO_ONE_JOIN_ROWS_MAX = 100_000;
173
+ /**
174
+ * WHY THIS IS A CONFIGURED LATENCY AND NOT A MEASURED ONE.
175
+ *
176
+ * The obvious next step from the formula above is to have the client measure
177
+ * its own round-trip time and derive the threshold at runtime. That was built
178
+ * and benchmarked, and it is NOT what ships, for a reason worth recording so it
179
+ * is not re-litigated blind:
180
+ *
181
+ * Every query's wall time is `roundTrip + serverWork`, and nothing in a
182
+ * duration distinguishes the two. An all-time MINIMUM reads a lucky packet
183
+ * (1.489ms on a link whose real per-statement cost was 2.862ms) and lands the
184
+ * threshold at half the true break-even. A MEDIAN over recent durations is
185
+ * accurate when the workload is cheap queries, but the workload being planned
186
+ * for here is precisely the expensive one: in the verification sweep the ring
187
+ * filled with 10-17ms relation queries, the estimate inflated, and `'auto'`
188
+ * held an 8,000-row query on the join plan, 1.30x slower than the better plan,
189
+ * WORSE than the fixed constant it replaced. Capping the median against a
190
+ * multiple of the floor mitigates it but turns the whole thing into a pair of
191
+ * magic numbers tuned against two synthetic links, which is the same mistake as
192
+ * a socket-tuned row count wearing a different hat.
193
+ *
194
+ * Round-trip time is a deployment fact, not a runtime discovery: it is fixed by
195
+ * where the app runs relative to the database, the operator knows it (or gets
196
+ * it from one `ping`), and it does not change between queries. So it is
197
+ * configuration. That also keeps plan selection deterministic, which matters
198
+ * for a library whose documented guarantee is that the strategy changes the
199
+ * plan and never the result.
200
+ */
201
+ /**
202
+ * The smallest plan-time parent-row bound at which `'auto'` moves a relation
203
+ * `_count` on a PROVEN-UNINDEXED probe to the grouped follow-up. Deliberately
204
+ * 2, i.e. "everything except a parent set provably bounded at one row".
205
+ *
206
+ * `_count` does NOT share the to-one break-even formula above, because its two
207
+ * plans do not differ by a small per-row penalty. Writing S for one scan of the
208
+ * child table and RTT for a round trip:
209
+ *
210
+ * inline(N) = N x S (a correlated COUNT(*) per parent row; see
211
+ * buildRelationCountExpr in relations.ts, the
212
+ * inline form is NOT a grouped scan)
213
+ * batched(N) = S + RTT (one `COUNT(*) ... GROUP BY fk` follow-up)
214
+ *
215
+ * so the crossover sits at `N = 1 + RTT/S` and, decisively, the two regrets are
216
+ * not comparable in kind:
217
+ *
218
+ * - choosing batched when inline would have won costs at most RTT, once, and
219
+ * ONLY at N = 1 (at N = 1 the difference is exactly RTT, and it shrinks to
220
+ * zero immediately after);
221
+ * - choosing inline when batched would have won costs (N - 1) x S, which is
222
+ * unbounded in the parent count.
223
+ *
224
+ * Measured on an UNINDEXED FK (PostgreSQL 16, 200K-row child table, 10K-row
225
+ * parent table, median of 11 interleaved reps per point, loopback;
226
+ * benchmarks/bench-count-strategy.ts):
227
+ *
228
+ * parents 1 2 3 5 20 100 1000 10000
229
+ * inline 4.3ms 8.3ms 12.3ms 20.1ms 79.2ms 417.5ms 3.06s 31.06s
230
+ * batched 5.2ms 4.8ms 4.8ms 5.2ms 6.1ms 11.5ms 9.9ms 28.4ms
231
+ * winner inline batched batched batched batched batched batched batched
232
+ * ratio 1.22x 1.73x 2.54x 3.84x 13.05x 36.42x 310.92x 1093.35x
233
+ *
234
+ * Inline wins exactly one cell, by 0.9ms, then loses the next by 1.73x and the
235
+ * last by 1093x. A skewed child distribution (half the rows on ten parents)
236
+ * moves nothing: same crossover at 2, same 1179x at 10,000. So the useful
237
+ * threshold is not a tunable row count, it is the one row where inline provably
238
+ * cannot lose. There is deliberately no config knob: the entire regret this rule
239
+ * can produce is one round trip, which is less than any knob would be worth, and
240
+ * `relationLoadStrategy: 'join'` already forces the single-statement plan.
241
+ *
242
+ * This applies ONLY to a probe the introspected index metadata PROVES unindexed.
243
+ * An INDEXED `_count` stays inline at every size measured (inline wins 1.30x to
244
+ * 2.06x from 1 to 10,000 parents, because the per-parent subquery collapses to
245
+ * an index-only scan costing ~0.001ms), and the partition below never demotes
246
+ * it.
247
+ */
248
+ exports.AUTO_COUNT_BATCH_MIN_PARENT_ROWS = 2;
173
249
  /**
174
250
  * Strict structural equality for a single SQL parameter value. Handles the
175
251
  * value shapes Turbine binds: primitives (incl. `NaN` and `bigint`), `null`/
@@ -472,15 +548,41 @@ class QueryInterface {
472
548
  this.txScoped = options?._txScoped ?? false;
473
549
  this.options = options;
474
550
  // Pre-compute column type lookup maps (TASK-26)
551
+ //
552
+ // Metadata can carry a column's database type in EITHER of two places: on
553
+ // the column entry (`dialectType` / `pgType`, what introspection and
554
+ // `turbine generate` emit) or in the table-level `dialectTypes` / `pgTypes`
555
+ // maps. Reading only the column entry is not a harmless miss for metadata
556
+ // that populates just the table-level maps: an unresolved type makes
557
+ // `coerceWriteValue` return a bound `Date` by identity, so a zone-less
558
+ // `date` / `timestamp` column stores the PROCESS's local calendar fields
559
+ // (and, because the read path pins UTC, a turbine-only round trip hides
560
+ // it). Consult both, column entry first. This costs one extra own-property
561
+ // lookup per column ONCE per QueryInterface, nothing per query.
475
562
  this.columnPgTypeMap = new Map();
476
563
  this.columnArrayTypeMap = new Map();
477
564
  this.crossSchemaTypeColumns = new Set();
565
+ const tableDialectTypes = this.tableMeta.dialectTypes;
566
+ const tablePgTypes = this.tableMeta.pgTypes;
478
567
  for (const col of this.tableMeta.columns) {
479
- this.columnPgTypeMap.set(col.name, col.dialectType ?? col.pgType);
480
- this.columnArrayTypeMap.set(col.name, col.arrayType ?? col.pgArrayType);
568
+ const dbType = col.dialectType ??
569
+ col.pgType ??
570
+ (tableDialectTypes && (0, utils_js_1.ownLookup)(tableDialectTypes, col.name)) ??
571
+ (tablePgTypes && (0, utils_js_1.ownLookup)(tablePgTypes, col.name));
572
+ if (dbType !== undefined)
573
+ this.columnPgTypeMap.set(col.name, dbType);
574
+ // The array map has the same shape of gap, but TableMetadata carries NO
575
+ // table-level array-type map (only `dialectTypes` / `pgTypes`), so there
576
+ // is nothing to fall back to. Deriving one from the resolved base type
577
+ // would invent an UNNEST cast the metadata never declared, so the column
578
+ // entry stays the only source.
579
+ const arrayType = col.arrayType ?? col.pgArrayType;
580
+ if (arrayType !== undefined)
581
+ this.columnArrayTypeMap.set(col.name, arrayType);
481
582
  if (col.pgTypeSchema !== undefined)
482
583
  this.crossSchemaTypeColumns.add(col.name);
483
584
  }
585
+ this.warnUntypedColumns();
484
586
  // Bind the shared WHERE-walk view once. `tableMeta` is immutable after this
485
587
  // point; the method wrappers forward to the (private) instance methods so
486
588
  // the walk needs no public accessors on the class.
@@ -504,6 +606,18 @@ class QueryInterface {
504
606
  scopedHostCache: this.scopedHostCache,
505
607
  columnPgTypeMap: this.columnPgTypeMap,
506
608
  columnArrayTypeMap: this.columnArrayTypeMap,
609
+ // The `utcTimestamps: false` opt-out has to reach the write/where web:
610
+ // omitting it here left `qi.utcTimestamps` undefined for the whole
611
+ // module, where `!== false` reads as opted IN, so the write side ignored
612
+ // the flag and rewrote binds the caller had opted out of.
613
+ //
614
+ // This half of the flag is PER CLIENT. The read half is not: it is the
615
+ // pg OID 1114 type parser, which `pg.types.setTypeParser` installs once
616
+ // per process. Two clients in one process therefore cannot hold
617
+ // different values, and TurbineClient refuses the second one rather than
618
+ // building a client whose writes and reads disagree (see
619
+ // `assertUtcTimestampsAgree` in client.ts).
620
+ utcTimestamps: this.utcTimestamps,
507
621
  crossSchemaTypeColumns: this.crossSchemaTypeColumns,
508
622
  get currentSkip() {
509
623
  return self.currentSkip;
@@ -534,6 +648,73 @@ class QueryInterface {
534
648
  paginationValue: (value, arg) => this.paginationValue(value, arg),
535
649
  };
536
650
  }
651
+ /**
652
+ * Dev-only, once per table: the columns whose database type is absent from
653
+ * BOTH the column entry and the table-level type maps, the residual case
654
+ * after the two-source resolution above.
655
+ *
656
+ * The set is deliberately every untyped column, not the `dateColumns`
657
+ * members. An unresolved type is precisely the state in which Turbine cannot
658
+ * say WHICH kind of column it is, so restricting the scan to `dateColumns`
659
+ * got it wrong in both directions: that set carries `timestamptz` (whose
660
+ * bind was never affected, since binding the `Date` is the correct thing to
661
+ * do for it) and omits `time` / `timetz` entirely (deliberately, see
662
+ * `timeOfDayKind` in schema.ts), which is the one kind that fails LOUDLY
663
+ * rather than silently. The message therefore names the columns and states
664
+ * what each kind does, rather than asserting a kind it cannot know.
665
+ *
666
+ * What is actually at stake per kind, all of it `coerceWriteValue` returning
667
+ * the bound `Date` by identity for want of a type:
668
+ * - zone-less `date` / `timestamp`: the driver serializes with the
669
+ * PROCESS's offset, so the column stores local calendar fields. Nothing
670
+ * surfaces at runtime, and a turbine-only round trip reads the same value
671
+ * back (the read path shifts by the same offset), so only an outside
672
+ * reader sees the drift.
673
+ * - `time` / `timetz`: the driver serializes a full ISO timestamp, which
674
+ * Postgres rejects with `22007 invalid input syntax for type time`.
675
+ * - `timestamptz` and every non-temporal type: unaffected.
676
+ *
677
+ * PostgreSQL only: the UTC bind rewrite is Postgres-gated (see
678
+ * `utcDateTimeWrites` in writes.ts), so on the other engines a missing type
679
+ * changes nothing about how a `Date` is bound. Suppressed under
680
+ * `NODE_ENV=production` like the other dev diagnostics and deduped through
681
+ * the shared registry, so a hot table logs one line for the process.
682
+ *
683
+ * Cannot throw on odd metadata: it walks `tableMeta.columns`, the array the
684
+ * constructor loop above has already iterated (and that client.ts validates
685
+ * as an array), never `dateColumns`, which is a `Set` in every first-party
686
+ * metadata path but arrives as a plain object from JSON-round-tripped
687
+ * metadata. A dev-only diagnostic that crashes a shape production would serve
688
+ * is worse than the bug it reports.
689
+ */
690
+ warnUntypedColumns() {
691
+ if (process.env.NODE_ENV === 'production')
692
+ return;
693
+ if (this.dialect.name !== 'postgresql')
694
+ return;
695
+ const untyped = [];
696
+ for (const col of this.tableMeta.columns) {
697
+ if (this.columnPgTypeMap.get(col.name) === undefined)
698
+ untyped.push(col.name);
699
+ }
700
+ if (untyped.length === 0)
701
+ return;
702
+ if (!(0, warn_registry_js_1.shouldWarnOnce)(warn_registry_js_1.WARN_NS.untypedDateColumn, this.table))
703
+ return;
704
+ // Bound the line on a wide table: the fix is per table, not per column, so
705
+ // the first few names are enough to recognize the metadata that produced it.
706
+ const MAX_NAMED = 12;
707
+ const named = untyped.slice(0, MAX_NAMED).join(', ');
708
+ const rest = untyped.length > MAX_NAMED ? ` (+${untyped.length - MAX_NAMED} more)` : '';
709
+ console.warn(`[turbine] table "${this.table}": no database type in metadata for column(s) ${named}${rest} (neither the ` +
710
+ "column entry's `dialectType`/`pgType` nor the table-level `dialectTypes`/`pgTypes` map). Turbine cannot " +
711
+ 'tell which of them are zone-less, so a `Date` written to one is bound by the driver as-is: right for ' +
712
+ "`timestamptz`, but a zone-less `date`/`timestamp` column then stores the PROCESS's local calendar fields " +
713
+ 'rather than UTC (silently, since the read path shifts back by the same offset), and a `time`/`timetz` ' +
714
+ 'column rejects the value outright (`22007 invalid input syntax for type time`). Columns whose type IS ' +
715
+ 'resolved, every `timestamptz` among them, are unaffected. Regenerate the metadata with ' +
716
+ '`npx turbine generate`, or set the column types in your `defineSchema` definition.');
717
+ }
537
718
  /** Quote an identifier through the active SQL dialect. */
538
719
  q(name) {
539
720
  return this.dialect.quoteIdentifier(name);
@@ -978,8 +1159,20 @@ class QueryInterface {
978
1159
  * `findFirst` pass `false` explicitly (their parent set is one row).
979
1160
  */
980
1161
  autoParentSetLarge(args) {
981
- const limit = args?.take ?? args?.limit ?? this.defaultLimit;
982
- return limit === undefined || limit > this.autoToOneThreshold();
1162
+ const bound = this.autoParentBound(args);
1163
+ return bound === undefined || bound > this.autoToOneThreshold();
1164
+ }
1165
+ /**
1166
+ * The plan-time UPPER BOUND on the parent-row count, or `undefined` when the
1167
+ * query is unbounded. This is the raw number behind
1168
+ * {@link autoParentSetLarge}; the `_count` rule needs the number itself
1169
+ * because its threshold ({@link AUTO_COUNT_BATCH_MIN_PARENT_ROWS}) is two
1170
+ * rows rather than the to-one break-even. `findUnique` / `findFirst` pass `1`
1171
+ * directly: their parent set is one row as a matter of the statement's shape,
1172
+ * not an estimate.
1173
+ */
1174
+ autoParentBound(args) {
1175
+ return args?.take ?? args?.limit ?? this.defaultLimit;
983
1176
  }
984
1177
  /**
985
1178
  * The parent-row count at which `'auto'` stops preferring the single-statement
@@ -1026,13 +1219,13 @@ class QueryInterface {
1026
1219
  *
1027
1220
  * Everything else (indexed to-many, composite-key, unknown) stays in `joinWith`
1028
1221
  * (byte-identical join). The reserved `_count` key falls back on rule 1 only,
1029
- * and only for a large parent set: an inline `_count` is one correlated
1030
- * `COUNT(*)` per parent row, so the grouped follow-up wins exactly when there
1031
- * are many parents, while for a handful of parents the extra round-trip costs
1032
- * more than the repeated (small) scans. Also returns the engaged relations for
1033
- * the dev note.
1222
+ * and on its OWN size rule: an inline `_count` is one correlated `COUNT(*)`
1223
+ * per parent row over an unindexed child table, so the grouped follow-up wins
1224
+ * from {@link AUTO_COUNT_BATCH_MIN_PARENT_ROWS} parent rows upward and inline
1225
+ * is preferred only when the parent set is provably bounded below that. Also
1226
+ * returns the engaged relations for the dev note.
1034
1227
  */
1035
- partitionWithForAuto(withClause, parentSetLarge) {
1228
+ partitionWithForAuto(withClause, parentSetLarge, parentBound) {
1036
1229
  const hasIndexInfo = (0, index_advisor_js_1.schemaHasIndexInfo)(this.schema);
1037
1230
  const joinWith = {};
1038
1231
  const batchedWith = {};
@@ -1042,7 +1235,8 @@ class QueryInterface {
1042
1235
  continue;
1043
1236
  if (key === '_count') {
1044
1237
  const cv = this.autoCountVerdict(spec, this.tableMeta);
1045
- if (hasIndexInfo && parentSetLarge && cv.unindexed && cv.eligible) {
1238
+ const countWorthBatching = parentBound === undefined || parentBound >= exports.AUTO_COUNT_BATCH_MIN_PARENT_ROWS;
1239
+ if (hasIndexInfo && countWorthBatching && cv.unindexed && cv.eligible) {
1046
1240
  batchedWith[key] = spec;
1047
1241
  engaged.push({ relation: '_count', reason: 'unindexed', miss: cv.miss });
1048
1242
  }
@@ -1080,7 +1274,7 @@ class QueryInterface {
1080
1274
  * to batched. Returns `null` (→ run the plain join path, byte-identical, same
1081
1275
  * cache keys) when nothing qualifies.
1082
1276
  */
1083
- planAuto(withArg, stableFlag, parentSetLarge) {
1277
+ planAuto(withArg, stableFlag, parentSetLarge, parentBound) {
1084
1278
  // Without DB-backed index info (code-first / defineSchema-only) no probe can
1085
1279
  // be PROVEN unindexed; the to-one cardinality rule does not depend on index
1086
1280
  // metadata, so it still applies.
@@ -1089,7 +1283,7 @@ class QueryInterface {
1089
1283
  const withClause = this.resolveStableOrder(stableFlag)
1090
1284
  ? this.applyStableRelationOrder(withArg, this.table)
1091
1285
  : withArg;
1092
- const split = this.partitionWithForAuto(withClause, parentSetLarge);
1286
+ const split = this.partitionWithForAuto(withClause, parentSetLarge, parentBound);
1093
1287
  if (Object.keys(split.batchedWith).length === 0)
1094
1288
  return null;
1095
1289
  return split;
@@ -1111,7 +1305,15 @@ class QueryInterface {
1111
1305
  const probe = e.miss
1112
1306
  ? `probe "${e.miss.table}"(${e.miss.columns.join(', ')}) has no covering index`
1113
1307
  : 'a probe in its subtree has no covering index';
1114
- console.warn(`[turbine] auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}). ` +
1308
+ // The `_count` case is the one people read as a needless demotion, because
1309
+ // the follow-up statement is a grouped COUNT and the inline form looks
1310
+ // like it would be one too. It is not: state the shape it replaced.
1311
+ const why = e.relation === '_count'
1312
+ ? ' The inline form is one correlated COUNT(*) re-evaluated per parent row, so on an unindexed ' +
1313
+ `probe it is one full scan of "${e.miss?.table ?? 'the child table'}" per parent row; the ` +
1314
+ 'follow-up is one grouped scan for the whole page.'
1315
+ : '';
1316
+ console.warn(`[turbine] auto strategy: relation "${e.relation}" on "${this.table}" loads batched (${probe}).${why} ` +
1115
1317
  "Create the covering index (or set `relationLoadStrategy: 'join'` to force the single-statement " +
1116
1318
  'plan); run `npx turbine doctor` for the exact CREATE INDEX SQL.');
1117
1319
  }
@@ -1527,8 +1729,10 @@ class QueryInterface {
1527
1729
  return this.runFindUniqueBatched(args);
1528
1730
  if (strategy === 'auto') {
1529
1731
  // findUnique's parent set is a single row: the join plan's correlated
1530
- // subqueries run once, so the cardinality rule never applies here.
1531
- const split = this.planAuto(args.with, args.stableRelationOrder, false);
1732
+ // subqueries run once, so the cardinality rule never applies here, and
1733
+ // an inline `_count` is one scan against the batched plan's one scan
1734
+ // plus a round trip.
1735
+ const split = this.planAuto(args.with, args.stableRelationOrder, false, 1);
1532
1736
  if (split)
1533
1737
  return this.runAutoSplit(args, split, true);
1534
1738
  }
@@ -1712,7 +1916,7 @@ class QueryInterface {
1712
1916
  if (strategy === 'batched')
1713
1917
  return this.runFindManyBatched(args);
1714
1918
  if (strategy === 'auto') {
1715
- const split = this.planAuto(args.with, args.stableRelationOrder, this.autoParentSetLarge(args));
1919
+ const split = this.planAuto(args.with, args.stableRelationOrder, this.autoParentSetLarge(args), this.autoParentBound(args));
1716
1920
  if (split)
1717
1921
  return this.runAutoSplit(args, split, false);
1718
1922
  }
@@ -2221,7 +2425,7 @@ class QueryInterface {
2221
2425
  }
2222
2426
  if (strategy === 'auto') {
2223
2427
  // findFirst is findMany + LIMIT 1: a one-row parent set.
2224
- const split = this.planAuto(args.with, args.stableRelationOrder, false);
2428
+ const split = this.planAuto(args.with, args.stableRelationOrder, false, 1);
2225
2429
  if (split) {
2226
2430
  const rows = (await this.runAutoSplit({ ...args, limit: 1 }, split, false));
2227
2431
  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,7 +7,7 @@
7
7
  * former monolithic `import { … } from './query.js'`.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.QueryInterface = exports.AUTO_TO_ONE_JOIN_ROWS_MIN = exports.AUTO_TO_ONE_JOIN_ROWS_MAX = exports.AUTO_TO_ONE_JOIN_MAX_ROWS = exports.AUTO_JOIN_PENALTY_MS_PER_ROW = exports.AUTO_ASSUMED_ROUND_TRIP_MS = exports.sqlToPreparedName = exports.quoteIdent = exports.OPERATOR_KEYS = exports.LRUCache = exports.fnv1a64Hex = exports.escSingleQuote = exports.escapeLike = exports.buildCorrelation = exports.postgresDialect = void 0;
10
+ exports.QueryInterface = exports.AUTO_TO_ONE_JOIN_ROWS_MIN = exports.AUTO_TO_ONE_JOIN_ROWS_MAX = exports.AUTO_TO_ONE_JOIN_MAX_ROWS = exports.AUTO_JOIN_PENALTY_MS_PER_ROW = exports.AUTO_COUNT_BATCH_MIN_PARENT_ROWS = exports.AUTO_ASSUMED_ROUND_TRIP_MS = exports.sqlToPreparedName = exports.quoteIdent = exports.OPERATOR_KEYS = exports.LRUCache = exports.fnv1a64Hex = exports.escSingleQuote = exports.escapeLike = exports.buildCorrelation = exports.postgresDialect = void 0;
11
11
  var dialect_js_1 = require("../dialect.js");
12
12
  Object.defineProperty(exports, "postgresDialect", { enumerable: true, get: function () { return dialect_js_1.postgresDialect; } });
13
13
  var utils_js_1 = require("./utils.js");
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "quoteIdent", { enumerable: true, get: function (
21
21
  Object.defineProperty(exports, "sqlToPreparedName", { enumerable: true, get: function () { return utils_js_1.sqlToPreparedName; } });
22
22
  var builder_js_1 = require("./builder.js");
23
23
  Object.defineProperty(exports, "AUTO_ASSUMED_ROUND_TRIP_MS", { enumerable: true, get: function () { return builder_js_1.AUTO_ASSUMED_ROUND_TRIP_MS; } });
24
+ Object.defineProperty(exports, "AUTO_COUNT_BATCH_MIN_PARENT_ROWS", { enumerable: true, get: function () { return builder_js_1.AUTO_COUNT_BATCH_MIN_PARENT_ROWS; } });
24
25
  Object.defineProperty(exports, "AUTO_JOIN_PENALTY_MS_PER_ROW", { enumerable: true, get: function () { return builder_js_1.AUTO_JOIN_PENALTY_MS_PER_ROW; } });
25
26
  Object.defineProperty(exports, "AUTO_TO_ONE_JOIN_MAX_ROWS", { enumerable: true, get: function () { return builder_js_1.AUTO_TO_ONE_JOIN_MAX_ROWS; } });
26
27
  Object.defineProperty(exports, "AUTO_TO_ONE_JOIN_ROWS_MAX", { enumerable: true, get: function () { return builder_js_1.AUTO_TO_ONE_JOIN_ROWS_MAX; } });
@@ -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
  };
@@ -106,4 +106,14 @@ exports.WARN_NS = {
106
106
  * engage is indistinguishable from one that does nothing, so say so once.
107
107
  */
108
108
  flattenFallback: 'flattenFallback',
109
+ /**
110
+ * Columns whose database type resolves from NEITHER the column entry nor the
111
+ * table-level type maps, so Turbine cannot tell which of them are zone-less
112
+ * and a bound `Date` is left to the driver rather than rewritten to a UTC
113
+ * literal (builder.ts `warnUntypedColumns`). The scan covers every column,
114
+ * not just `dateColumns`, because an unresolved type is precisely the state
115
+ * in which the kind is unknown; the warning emits one line per table listing
116
+ * the offenders, not one per column. The namespace name is historical.
117
+ */
118
+ untypedDateColumn: 'untypedDateColumn',
109
119
  };
@@ -134,6 +134,36 @@ function buildReselectByWhere(qi, whereObj) {
134
134
  const where = clause ? ` WHERE ${clause}` : '';
135
135
  return { sql: `SELECT ${writeReselectSelection(qi)} FROM ${qi.q(qi.table)}${where}`, params };
136
136
  }
137
+ /**
138
+ * Build the all-defaults INSERT for a `data` that names no column, via the
139
+ * dialect's `buildDefaultValuesInsertStatement` hook.
140
+ *
141
+ * `INSERT INTO t () VALUES ()` (what the column-driven builders render for an
142
+ * empty `data`) is a syntax error everywhere but MySQL, and the correct
143
+ * statement differs per engine, so the shape lives behind the dialect seam.
144
+ * `{}` is easy to reach honestly, a handler that assembles its payload from
145
+ * optional request fields produces it on a request that supplied none of them,
146
+ * so the row of pure defaults is inserted and returned rather than crashing.
147
+ * The empty-`update` no-op in {@link buildUpdate} has the same rationale.
148
+ *
149
+ * A table with no usable defaults still fails, correctly, with the database's
150
+ * own NOT NULL violation (E010): nothing here pre-empts that.
151
+ *
152
+ * A dialect predating the hook raises E017 rather than emitting SQL its engine
153
+ * will reject.
154
+ */
155
+ function buildDefaultValuesInsert(qi, rowCount, skipDuplicates) {
156
+ const build = qi.dialect.buildDefaultValuesInsertStatement;
157
+ if (!build) {
158
+ throw new errors_js_1.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`.');
159
+ }
160
+ return build.call(qi.dialect, {
161
+ table: qi.q(qi.table),
162
+ rowCount,
163
+ skipDuplicates,
164
+ returning: writeReturningColumns(qi),
165
+ });
166
+ }
137
167
  function buildCreate(qi, args) {
138
168
  assertWritable(qi, 'create');
139
169
  assertNoGeneratedColumns(qi, args.data, 'create');
@@ -142,12 +172,15 @@ function buildCreate(qi, args) {
142
172
  const params = entries.map(([k, v]) => coerceWriteValue(qi, k, v));
143
173
  // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
144
174
  const placeholders = entries.map(([k], i) => `${qi.p(i + 1)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`);
145
- const sql = qi.dialect.buildInsertStatement({
146
- table: qi.q(qi.table),
147
- columns,
148
- valuePlaceholders: placeholders,
149
- returning: writeReturningColumns(qi),
150
- });
175
+ // `data: {}` (or all-undefined) names no column: insert a row of defaults.
176
+ const sql = entries.length === 0
177
+ ? buildDefaultValuesInsert(qi, 1)
178
+ : qi.dialect.buildInsertStatement({
179
+ table: qi.q(qi.table),
180
+ columns,
181
+ valuePlaceholders: placeholders,
182
+ returning: writeReturningColumns(qi),
183
+ });
151
184
  return {
152
185
  sql,
153
186
  params,
@@ -194,6 +227,69 @@ function makeCreateReselect(qi, insertSql, insertParams, data) {
194
227
  return exec(`SELECT ${writeReselectSelection(qi)} FROM ${qi.q(qi.table)}${where}`, selParams);
195
228
  };
196
229
  }
230
+ /**
231
+ * The fields a write's `data` object actually names.
232
+ *
233
+ * A key whose value is `undefined` is NOT named: single-row {@link buildCreate}
234
+ * filters those out of its column list, so the column takes its declared
235
+ * default. `createMany` reads its rows through this same helper, so
236
+ * `{ n: undefined }` and `{}` mean the identical thing on both paths.
237
+ */
238
+ function definedKeys(row) {
239
+ return Object.keys(row).filter((k) => row[k] !== undefined);
240
+ }
241
+ /**
242
+ * Refuse a `createMany` whose rows do not all name the SAME fields.
243
+ *
244
+ * `createMany` compiles ONE statement, and its column list is taken from the
245
+ * first row alone, so a row that disagrees with the first row loses data in
246
+ * both directions and silently:
247
+ *
248
+ * - a field the first row names and a later row OMITS is bound as NULL,
249
+ * overwriting that column's declared default (and failing outright on a NOT
250
+ * NULL column that has one);
251
+ * - a field only a LATER row names is not in the column list at all, so its
252
+ * value never reaches the database.
253
+ *
254
+ * Neither shape is expressible as a single statement on every engine. The
255
+ * row-major `VALUES` form can carry a per-cell `DEFAULT` keyword on
256
+ * PostgreSQL, MySQL and SQL Server, but SQLite has no such grammar (`VALUES
257
+ * (1, DEFAULT)` is a parse error there), and adopting it would also drop
258
+ * PostgreSQL off the column-major `UNNEST` form that binds one array per
259
+ * column rather than one placeholder per cell. So the honest answer is a
260
+ * refusal that names the offending row and its differing columns.
261
+ *
262
+ * A single row is trivially uniform, and rows that all name the same fields
263
+ * (the overwhelmingly common shape) cost one `Object.keys` pass and emit
264
+ * byte-identical SQL.
265
+ */
266
+ function assertUniformCreateManyRows(qi, rows, firstKeys) {
267
+ const expected = new Set(firstKeys);
268
+ for (let i = 1; i < rows.length; i++) {
269
+ const rowKeys = definedKeys(rows[i]);
270
+ const unexpected = rowKeys.filter((k) => !expected.has(k));
271
+ // No stranger and the same count means the same set (object keys are unique).
272
+ if (unexpected.length === 0 && rowKeys.length === expected.size)
273
+ continue;
274
+ const present = new Set(rowKeys);
275
+ const missing = firstKeys.filter((k) => !present.has(k));
276
+ const parts = [];
277
+ if (missing.length > 0)
278
+ parts.push(`does not supply ${quoteList(missing)}`);
279
+ if (unexpected.length > 0)
280
+ parts.push(`supplies ${quoteList(unexpected)}, which the first row does not`);
281
+ throw new errors_js_1.ValidationError(`[turbine] createMany on "${qi.table}": row ${i} ${parts.join(' and ')}. ` +
282
+ 'Every row must supply the same fields: createMany builds ONE statement whose column list comes from the ' +
283
+ "first row, so a field a later row omits would be written as NULL over that column's default, and a field " +
284
+ 'only a later row names would be dropped. Supply the field explicitly on every row (a field set to ' +
285
+ '`undefined` counts as omitted, exactly as it does in `create`), or split the call into one createMany per ' +
286
+ 'row shape.');
287
+ }
288
+ }
289
+ /** `a, b` → `"a", "b"`, for the field lists in {@link assertUniformCreateManyRows}. */
290
+ function quoteList(fields) {
291
+ return fields.map((f) => `"${f}"`).join(', ');
292
+ }
197
293
  function buildCreateMany(qi, args) {
198
294
  const qt = qi.q(qi.table);
199
295
  if (args.data.length === 0) {
@@ -208,7 +304,19 @@ function buildCreateMany(qi, args) {
208
304
  for (const row of args.data) {
209
305
  assertNoGeneratedColumns(qi, row, 'createMany');
210
306
  }
211
- const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
307
+ const keys = definedKeys(args.data[0]);
308
+ assertUniformCreateManyRows(qi, args.data, keys);
309
+ // No column named by the first row: every row is pure defaults (the bulk
310
+ // counterpart of `create({ data: {} })`, see buildDefaultValuesInsert). The
311
+ // uniformity check above has already established that EVERY row is empty.
312
+ if (keys.length === 0) {
313
+ return {
314
+ sql: buildDefaultValuesInsert(qi, args.data.length, args.skipDuplicates),
315
+ params: [],
316
+ transform: (result) => result.rows.map((row) => parseWriteRow(qi, row)),
317
+ tag: `${qi.table}.createMany`,
318
+ };
319
+ }
212
320
  const columns = keys.map((k) => qi.toColumn(k));
213
321
  const rowValues = args.data.map((row) => {
214
322
  const record = row;
@@ -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
@@ -490,6 +490,17 @@ exports.sqliteDialect = {
490
490
  params: input.rowValues.flat(),
491
491
  };
492
492
  },
493
+ buildDefaultValuesInsertStatement(input) {
494
+ // SQLite's `DEFAULT VALUES` inserts exactly one row and the engine accepts
495
+ // neither `VALUES (DEFAULT, …)` nor MySQL's empty `VALUES ()` tuple, so
496
+ // there is no multi-row all-defaults statement to emit.
497
+ if (input.rowCount !== 1) {
498
+ throw new errors_js_1.UnsupportedFeatureError(`createMany with ${input.rowCount} empty data rows`, 'sqlite', 'SQLite INSERT … DEFAULT VALUES inserts a single row and has no multi-row form; ' +
499
+ 'issue one create({ data: {} }) per row.');
500
+ }
501
+ const conflict = input.skipDuplicates ? ' ON CONFLICT DO NOTHING' : '';
502
+ return `INSERT INTO ${input.table} DEFAULT VALUES${conflict}${this.buildReturningClause(input.returning)}`;
503
+ },
493
504
  buildUpsertStatement(input) {
494
505
  return (`INSERT INTO ${input.table} (${input.insertColumns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})` +
495
506
  ` ON CONFLICT (${input.conflictColumns.join(', ')}) DO UPDATE SET ${input.updateSetClauses.join(', ')}` +
@@ -786,7 +797,8 @@ function openSqliteDatabase(target, options) {
786
797
  *
787
798
  * @param target A SQLite file path, `':memory:'`, or an open `DatabaseSync`.
788
799
  * @param schema Introspected or hand-written {@link SchemaMetadata}.
789
- * @param options Optional pragmas + logging / defaultLimit / warnOnUnlimited.
800
+ * @param options Optional SQLite pragmas plus any client-level
801
+ * {@link TurbineConfig} field (see {@link EngineClientConfig}).
790
802
  *
791
803
  * @example
792
804
  * ```ts
@@ -797,12 +809,14 @@ function openSqliteDatabase(target, options) {
797
809
  function turbineSqlite(target, schema, options = {}) {
798
810
  const db = typeof target === 'string' ? openSqliteDatabase(target, options) : target;
799
811
  const pool = new SqlitePool(db);
812
+ // Everything that is not a SQLite pragma is client config and rides through
813
+ // untouched, so a new TurbineConfig option works here the day it lands. The
814
+ // engine-owned keys come last, so no caller can unbind the dialect.
815
+ const { wal: _wal, busyTimeoutMs: _busyTimeoutMs, foreignKeys: _foreignKeys, ...clientConfig } = options;
800
816
  return new client_js_1.TurbineClient({
817
+ ...clientConfig,
801
818
  pool,
802
819
  dialect: exports.sqliteDialect,
803
820
  preparedStatements: false,
804
- logging: options.logging,
805
- defaultLimit: options.defaultLimit,
806
- warnOnUnlimited: options.warnOnUnlimited,
807
821
  }, schema);
808
822
  }