turbine-orm 0.90.1 → 0.90.2-next.91484a2

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.
@@ -579,7 +579,9 @@ export interface TurbineConfig {
579
579
  * connection, so a statement named on one is missing (or already present) on
580
580
  * the next, unless the pooler tracks prepared statements (PgBouncer 1.21+
581
581
  * with `max_prepared_statements` above 0). Set `true` on such a pooler only
582
- * when it does; Turbine warns once in development when you do.
582
+ * when it does. Turbine warns once (`prepared-statements-on-pooler`) when you
583
+ * set `true` and the connection settings name a pooler: Turbine's own, or
584
+ * the `options` of a `pg.Pool` you pass as `pool` (since 0.90.2).
583
585
  *
584
586
  * Override with `TURBINE_DISABLE_PREPARED=1` env var.
585
587
  *
@@ -1132,6 +1134,17 @@ export declare class TurbineClient {
1132
1134
  * the target leaves unset is `PGHOST` / `PGPORT`.
1133
1135
  */
1134
1136
  private static poolerEndpoint;
1137
+ /**
1138
+ * The pooler a pool the CALLER supplied connects to, read from the settings
1139
+ * node-postgres keeps on the pool (`pool.options`, which `pg.Pool` and the
1140
+ * drivers built on it expose), the way {@link poolerEndpoint} reads Turbine's
1141
+ * own: the connection string wins over `host` / `port`, and an unset host or
1142
+ * port is `PGHOST` / `PGPORT`. `undefined` for a pool that exposes no
1143
+ * settings (another driver) or names no pooler.
1144
+ */
1145
+ private static suppliedPoolEndpoint;
1146
+ /** The first of `targets` that names a pooler, as `host:port`, after filling unset parts from the environment. */
1147
+ private static firstPooledTarget;
1135
1148
  /** Always on: named statements behind a transaction pooler fail in production first. */
1136
1149
  private static warnPreparedOnPooler;
1137
1150
  /**
@@ -40,6 +40,7 @@ const logger_js_1 = require("./logger.js");
40
40
  const nested_write_js_1 = require("./nested-write.js");
41
41
  const observe_js_1 = require("./observe.js");
42
42
  const pipeline_js_1 = require("./pipeline.js");
43
+ const builder_js_1 = require("./query/builder.js");
43
44
  const index_js_1 = require("./query/index.js");
44
45
  const row_lock_js_1 = require("./query/row-lock.js");
45
46
  const sql_comment_js_1 = require("./query/sql-comment.js");
@@ -814,7 +815,7 @@ class TransactionClient {
814
815
  // Create a QueryInterface that uses the transaction client as its "pool"
815
816
  // We use a proxy pool that routes queries through the transaction client
816
817
  const txPool = this.createTxPool();
817
- const txOpts = { ...this.queryOptions, _txScoped: true };
818
+ const txOpts = (0, builder_js_1.txQueryOptions)(this.queryOptions);
818
819
  qi = txOpts.queryInterfaceFactory
819
820
  ? (0, table_client_js_1.asQueryInterface)(txOpts.queryInterfaceFactory(txPool, name, this.schema, this.middlewares, txOpts))
820
821
  : new index_js_1.QueryInterface(txPool, name, this.schema, this.middlewares, txOpts);
@@ -1360,8 +1361,12 @@ class TurbineClient {
1360
1361
  // Respect env var kill switch
1361
1362
  const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
1362
1363
  const pooler = config.pool ? undefined : TurbineClient.poolerEndpoint(config);
1363
- if (config.preparedStatements === true && pooler !== undefined) {
1364
- TurbineClient.warnPreparedOnPooler(pooler, logger);
1364
+ // A supplied pool defaults to unnamed statements, so its endpoint is read
1365
+ // only to warn when they are turned on behind a pooler.
1366
+ if (config.preparedStatements === true) {
1367
+ const endpoint = config.pool ? TurbineClient.suppliedPoolEndpoint(config.pool) : pooler;
1368
+ if (endpoint !== undefined)
1369
+ TurbineClient.warnPreparedOnPooler(endpoint, logger);
1365
1370
  }
1366
1371
  this.errorMessageMode = config.errorMessages ?? 'safe';
1367
1372
  this.errorMessagesSafe = this.errorMessageMode === 'safe';
@@ -1732,6 +1737,43 @@ class TurbineClient {
1732
1737
  if (typeof replica === 'string' && replica)
1733
1738
  targets.push((0, connection_url_js_1.parseConnectionTarget)(replica));
1734
1739
  }
1740
+ return TurbineClient.firstPooledTarget(targets);
1741
+ }
1742
+ /**
1743
+ * The pooler a pool the CALLER supplied connects to, read from the settings
1744
+ * node-postgres keeps on the pool (`pool.options`, which `pg.Pool` and the
1745
+ * drivers built on it expose), the way {@link poolerEndpoint} reads Turbine's
1746
+ * own: the connection string wins over `host` / `port`, and an unset host or
1747
+ * port is `PGHOST` / `PGPORT`. `undefined` for a pool that exposes no
1748
+ * settings (another driver) or names no pooler.
1749
+ */
1750
+ static suppliedPoolEndpoint(pool) {
1751
+ // A diagnostic: a pool whose settings cannot be read is simply not checked,
1752
+ // and nothing here may fail the constructor.
1753
+ let target;
1754
+ try {
1755
+ const options = pool.options;
1756
+ if (options === null || typeof options !== 'object')
1757
+ return undefined;
1758
+ const { connectionString, host, port } = options;
1759
+ if (typeof connectionString === 'string' && connectionString) {
1760
+ target = (0, connection_url_js_1.parseConnectionTarget)(connectionString);
1761
+ }
1762
+ else {
1763
+ const portNumber = typeof port === 'string' ? Number.parseInt(port, 10) : port;
1764
+ target = {
1765
+ host: typeof host === 'string' && host ? host.toLowerCase() : null,
1766
+ port: typeof portNumber === 'number' && Number.isInteger(portNumber) ? portNumber : null,
1767
+ };
1768
+ }
1769
+ }
1770
+ catch {
1771
+ return undefined;
1772
+ }
1773
+ return TurbineClient.firstPooledTarget([target]);
1774
+ }
1775
+ /** The first of `targets` that names a pooler, as `host:port`, after filling unset parts from the environment. */
1776
+ static firstPooledTarget(targets) {
1735
1777
  const environment = { host: pgEnv('PGHOST'), port: pgEnv('PGPORT') };
1736
1778
  for (const target of targets) {
1737
1779
  const found = (0, connection_url_js_1.detectPoolerTarget)((0, connection_url_js_1.fillTargetFromEnvironment)(target, environment));
@@ -212,6 +212,10 @@ function buildGroupBy(qi, args) {
212
212
  }
213
213
  else {
214
214
  const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
215
+ // The alias below is caller text (`alias`, else the last path segment),
216
+ // written into the statement, so the statement goes unnamed: a key
217
+ // chosen per request would otherwise claim a prepared name per value.
218
+ qi.markVariableArity();
215
219
  assertAggregatePiiOptIn(qi.table, meta, entry.field, col, 'groupBy JSON `by` key', includePii);
216
220
  params.push(whereMod.jsonPathParam(qi, entry.path));
217
221
  const extract = qi.dialect.buildJsonPathExtract(qi.q(col), qi.p(params.length));
@@ -295,6 +299,8 @@ function buildGroupBy(qi, args) {
295
299
  continue;
296
300
  }
297
301
  const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
302
+ // `key` is a caller-chosen label written into the alias: unnamed, as above.
303
+ qi.markVariableArity();
298
304
  if (aggKey === '_min' || aggKey === '_max') {
299
305
  assertAggregatePiiOptIn(qi.table, meta, target.field, col, `groupBy ${aggKey} JSON target`, includePii);
300
306
  }
@@ -177,6 +177,7 @@ export declare const AUTO_COUNT_BATCH_MIN_PARENT_ROWS = 2;
177
177
  export declare function unlockNestedWriteTx<X extends {
178
178
  table(name: string): Record<string, unknown>;
179
179
  }>(tx: X): X;
180
+ export declare function txQueryOptions(options: QueryInterfaceOptions | undefined): QueryInterfaceOptions;
180
181
  export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, TemporalInfinityReading, } from './deferred.js';
181
182
  import type { DeferredQuery, MiddlewareFn, QueryInterfaceOptions } from './deferred.js';
182
183
  export declare class QueryInterface<T extends object, R extends object = {}, M extends TableInputTypes = DefaultInputTypes<T, R>> {
@@ -904,8 +905,11 @@ export declare class QueryInterface<T extends object, R extends object = {}, M e
904
905
  */
905
906
  private prepareBatchedBase;
906
907
  /**
907
- * Return cache hit/miss statistics for this QueryInterface instance.
908
- * Useful for monitoring and benchmarking.
908
+ * SQL template cache statistics. `hits`, `misses` and `hitRate` count this
909
+ * accessor's own lookups. `size` is the entries in the cache it reads, which
910
+ * since 0.90.2 is shared with every accessor built from the same client
911
+ * options for this table: a client's primary and replica accessors share
912
+ * one, and all of its transactions share another (see `templateCacheFor`).
909
913
  */
910
914
  cacheStats(): {
911
915
  hits: number;
@@ -951,6 +955,19 @@ export declare class QueryInterface<T extends object, R extends object = {}, M e
951
955
  * variable-arity and the measured reason it must not be named.
952
956
  */
953
957
  private buildCacheEntry;
958
+ /**
959
+ * The prepared-statement name for `sql`: empty (sent unnamed) for a
960
+ * variable-arity shape, else derived from the text.
961
+ */
962
+ private preparedNameOf;
963
+ /**
964
+ * Name a statement the SQL template cache does not hold (`aggregate`,
965
+ * `groupBy`): the build runs between a reset and a read of the
966
+ * variable-arity flag, exactly as in {@link buildCacheEntry}, so the same
967
+ * shapes stay unnamed. These used to be sent unnamed always, so with
968
+ * prepared statements on the server re-parsed every `aggregate`.
969
+ */
970
+ private withPreparedName;
954
971
  /**
955
972
  * The SQL-cache lockstep checks (see the comment above `CACHE_MISMATCH_WARN_NS`).
956
973
  *
@@ -47,6 +47,7 @@ var __importStar = (this && this.__importStar) || (function () {
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
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
  exports.unlockNestedWriteTx = unlockNestedWriteTx;
50
+ exports.txQueryOptions = txQueryOptions;
50
51
  const checkout_js_1 = require("../checkout.js");
51
52
  const connection_guard_js_1 = require("../connection-guard.js");
52
53
  const diagnostics_js_1 = require("../diagnostics.js");
@@ -539,6 +540,69 @@ const JSON_ENCODINGS = Object.freeze(['object', 'positional']);
539
540
  function defaultJsonEncoding(dialect) {
540
541
  return dialect.capabilities.positionalJson ? 'positional' : 'object';
541
542
  }
543
+ /**
544
+ * SQL template caches shared by every QueryInterface built from ONE options
545
+ * object, for one schema and one table.
546
+ *
547
+ * A transaction builds its own QueryInterface per table, so before this each
548
+ * `$transaction` started with an empty cache and rebuilt the SQL of every
549
+ * statement it sent. TransactionClient now hands every transaction of a client
550
+ * the same options object (`txQueryOptions` in client.ts), so they share one
551
+ * cache per table and a statement is built once per process, as it is outside
552
+ * a transaction.
553
+ *
554
+ * THE KEY IS THE OPTIONS OBJECT'S IDENTITY, deliberately, not a fingerprint of
555
+ * its fields. Options change the SQL (`defaultLimit`, `jsonEncoding`,
556
+ * `globalFilters`, the dialect, ...), and a cache key does not carry them,
557
+ * because one QueryInterface never sees two values. A copy that changes any
558
+ * field is a new object and so gets its own caches: the batched loader's
559
+ * children (`defaultLimit: undefined`) can never be served the parent's SQL,
560
+ * and no list of SQL-affecting options exists to fall out of date. Nothing
561
+ * mutates an options object after construction.
562
+ */
563
+ const sharedTemplateCaches = new WeakMap();
564
+ /**
565
+ * The options a transaction's query interfaces are built with: `options` plus
566
+ * `_txScoped`. ONE object per options object, so every transaction of a client
567
+ * (and every nested write inside one) builds its query interfaces from the same
568
+ * object and shares their template caches (see {@link templateCacheFor}). A
569
+ * fresh copy per transaction gave each `$transaction` an empty cache, so every
570
+ * statement in it rebuilt its SQL. Options that are already transaction-scoped
571
+ * are returned as they are.
572
+ */
573
+ const txOptionsCache = new WeakMap();
574
+ function txQueryOptions(options) {
575
+ if (options === undefined)
576
+ return { _txScoped: true };
577
+ if (options._txScoped === true)
578
+ return options;
579
+ let tx = txOptionsCache.get(options);
580
+ if (tx === undefined) {
581
+ tx = { ...options, _txScoped: true };
582
+ txOptionsCache.set(options, tx);
583
+ }
584
+ return tx;
585
+ }
586
+ function templateCacheFor(options, schema, table, capacity) {
587
+ if (options === undefined)
588
+ return new utils_js_1.LRUCache(capacity);
589
+ let bySchema = sharedTemplateCaches.get(options);
590
+ if (bySchema === undefined) {
591
+ bySchema = new WeakMap();
592
+ sharedTemplateCaches.set(options, bySchema);
593
+ }
594
+ let byTable = bySchema.get(schema);
595
+ if (byTable === undefined) {
596
+ byTable = new Map();
597
+ bySchema.set(schema, byTable);
598
+ }
599
+ let cache = byTable.get(table);
600
+ if (cache === undefined) {
601
+ cache = new utils_js_1.LRUCache(capacity);
602
+ byTable.set(table, cache);
603
+ }
604
+ return cache;
605
+ }
542
606
  class QueryInterface {
543
607
  pool;
544
608
  table;
@@ -832,7 +896,7 @@ class QueryInterface {
832
896
  // treated as the default rather than throwing.
833
897
  const sqlCacheSize = options?.sqlCacheSize;
834
898
  this.sqlCacheEnabled = options?.sqlCache !== false && sqlCacheSize !== 0;
835
- this.sqlTemplateCache = new utils_js_1.LRUCache(sqlCacheSize !== undefined && sqlCacheSize > 0 ? Math.floor(sqlCacheSize) : 1000);
899
+ this.sqlTemplateCache = templateCacheFor(options, schema, table, sqlCacheSize !== undefined && sqlCacheSize > 0 ? Math.floor(sqlCacheSize) : 1000);
836
900
  this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
837
901
  (0, dialect_js_1.assertDialectCapabilities)(this.dialect);
838
902
  this.sqlCommenter =
@@ -2221,8 +2285,11 @@ class QueryInterface {
2221
2285
  return { baseArgs, strip: proj.strip };
2222
2286
  }
2223
2287
  /**
2224
- * Return cache hit/miss statistics for this QueryInterface instance.
2225
- * Useful for monitoring and benchmarking.
2288
+ * SQL template cache statistics. `hits`, `misses` and `hitRate` count this
2289
+ * accessor's own lookups. `size` is the entries in the cache it reads, which
2290
+ * since 0.90.2 is shared with every accessor built from the same client
2291
+ * options for this table: a client's primary and replica accessors share
2292
+ * one, and all of its transactions share another (see `templateCacheFor`).
2226
2293
  */
2227
2294
  cacheStats() {
2228
2295
  const total = this.cacheHits + this.cacheMisses;
@@ -2295,15 +2362,33 @@ class QueryInterface {
2295
2362
  this.variableArityShape = false;
2296
2363
  this.cacheMisses++;
2297
2364
  this.lastMissParams = built;
2365
+ return { sql, name: this.preparedNameOf(sql, variableArity), paramCount: built.length };
2366
+ }
2367
+ /**
2368
+ * The prepared-statement name for `sql`: empty (sent unnamed) for a
2369
+ * variable-arity shape, else derived from the text.
2370
+ */
2371
+ preparedNameOf(sql, variableArity) {
2372
+ if (variableArity)
2373
+ return '';
2298
2374
  // With `sqlComment` putting a comment on every statement, the template's own
2299
2375
  // name is never sent (the execute seam sends a `c_` name derived from it),
2300
2376
  // so it is not claimed from the budget: only names that reach the wire do.
2301
- const name = variableArity
2302
- ? ''
2303
- : this.sqlCommenter?.alwaysComments
2304
- ? (0, utils_js_1.sqlToPreparedName)(sql)
2305
- : (0, utils_js_1.boundedPreparedName)(sql);
2306
- return { sql, name, paramCount: built.length };
2377
+ return this.sqlCommenter?.alwaysComments ? (0, utils_js_1.sqlToPreparedName)(sql) : (0, utils_js_1.boundedPreparedName)(sql);
2378
+ }
2379
+ /**
2380
+ * Name a statement the SQL template cache does not hold (`aggregate`,
2381
+ * `groupBy`): the build runs between a reset and a read of the
2382
+ * variable-arity flag, exactly as in {@link buildCacheEntry}, so the same
2383
+ * shapes stay unnamed. These used to be sent unnamed always, so with
2384
+ * prepared statements on the server re-parsed every `aggregate`.
2385
+ */
2386
+ withPreparedName(build) {
2387
+ this.variableArityShape = false;
2388
+ const deferred = build();
2389
+ const variableArity = this.variableArityShape;
2390
+ this.variableArityShape = false;
2391
+ return { ...deferred, preparedName: this.preparedNameOf(deferred.sql, variableArity) };
2307
2392
  }
2308
2393
  /**
2309
2394
  * The SQL-cache lockstep checks (see the comment above `CACHE_MISMATCH_WARN_NS`).
@@ -4264,6 +4349,8 @@ class QueryInterface {
4264
4349
  return rows.length > 0 ? rows[0] : null;
4265
4350
  },
4266
4351
  tag: `${this.table}.findFirst`,
4352
+ // findMany's name: the same statement, so the same prepared statement.
4353
+ ...(deferred.preparedName !== undefined ? { preparedName: deferred.preparedName } : {}),
4267
4354
  ...(deferred.rowLock ? { rowLock: true } : {}),
4268
4355
  };
4269
4356
  }
@@ -4297,6 +4384,7 @@ class QueryInterface {
4297
4384
  return row;
4298
4385
  },
4299
4386
  tag: `${this.table}.findFirstOrThrow`,
4387
+ ...(inner.preparedName !== undefined ? { preparedName: inner.preparedName } : {}),
4300
4388
  ...(inner.rowLock ? { rowLock: true } : {}),
4301
4389
  };
4302
4390
  }
@@ -4330,6 +4418,7 @@ class QueryInterface {
4330
4418
  return row;
4331
4419
  },
4332
4420
  tag: `${this.table}.findUniqueOrThrow`,
4421
+ ...(inner.preparedName !== undefined ? { preparedName: inner.preparedName } : {}),
4333
4422
  ...(inner.rowLock ? { rowLock: true } : {}),
4334
4423
  };
4335
4424
  }
@@ -4499,7 +4588,10 @@ class QueryInterface {
4499
4588
  const pool = this.pool;
4500
4589
  const schema = this.schema;
4501
4590
  const middlewares = this.middlewares;
4502
- const opts = { ...this.options, _txScoped: true };
4591
+ // The client's transaction options object, not a copy per call: the SQL
4592
+ // template cache is shared by options-object identity, so a fresh copy
4593
+ // rebuilt the SQL of every child statement of every nested write.
4594
+ const opts = txQueryOptions(this.options);
4503
4595
  return {
4504
4596
  schema,
4505
4597
  tx: unlockNestedWriteTx(this.makeTxProxy(pool, schema, middlewares, opts)),
@@ -4690,10 +4782,12 @@ class QueryInterface {
4690
4782
  // Aggregate / groupBy compilation (extracted to aggregates.ts).
4691
4783
  // ---------------------------------------------------------------------------
4692
4784
  buildGroupBy(args) {
4693
- return aggMod.buildGroupBy(this.ctx, this.normalizeArgs(args));
4785
+ const normalized = this.normalizeArgs(args);
4786
+ return this.withPreparedName(() => aggMod.buildGroupBy(this.ctx, normalized));
4694
4787
  }
4695
4788
  buildAggregate(args) {
4696
- return aggMod.buildAggregate(this.ctx, this.normalizeArgs(args));
4789
+ const normalized = this.normalizeArgs(args);
4790
+ return this.withPreparedName(() => aggMod.buildAggregate(this.ctx, normalized));
4697
4791
  }
4698
4792
  // -------------------------------------------------------------------------
4699
4793
  // aggregate, standalone aggregation without groupBy
@@ -75,7 +75,6 @@ exports.fingerprintSet = fingerprintSet;
75
75
  exports.collectSetParams = collectSetParams;
76
76
  const dialect_js_1 = require("../dialect.js");
77
77
  const errors_js_1 = require("../errors.js");
78
- const schema_js_1 = require("../schema.js");
79
78
  const column_ref_js_1 = require("./column-ref.js");
80
79
  const compound_unique_js_1 = require("./compound-unique.js");
81
80
  const expr_js_1 = require("./expr.js");
@@ -818,6 +817,10 @@ function definedKeys(qi, row) {
818
817
  function assertUniformCreateManyRows(qi, rows, firstKeys) {
819
818
  const expected = new Set(firstKeys);
820
819
  for (let i = 1; i < rows.length; i++) {
820
+ // The common case, decided without ordering the row's keys: as many
821
+ // defined keys as the first row, each one of its keys.
822
+ if (namesExactly(rows[i], expected))
823
+ continue;
821
824
  const rowKeys = definedKeys(qi, rows[i]);
822
825
  const unexpected = rowKeys.filter((k) => !expected.has(k));
823
826
  // No stranger and the same count means the same set (object keys are unique).
@@ -838,6 +841,18 @@ function assertUniformCreateManyRows(qi, rows, firstKeys) {
838
841
  'row shape.');
839
842
  }
840
843
  }
844
+ /** Whether `row`'s defined keys are exactly `keys` (object keys are unique, so count plus membership decides it). */
845
+ function namesExactly(row, keys) {
846
+ let count = 0;
847
+ for (const key in row) {
848
+ if (!Object.hasOwn(row, key) || row[key] === undefined)
849
+ continue;
850
+ if (!keys.has(key))
851
+ return false;
852
+ count++;
853
+ }
854
+ return count === keys.size;
855
+ }
841
856
  /** `a, b` → `"a", "b"`, for the field lists in {@link assertUniformCreateManyRows}. */
842
857
  function quoteList(fields) {
843
858
  return fields.map((f) => `"${f}"`).join(', ');
@@ -920,8 +935,15 @@ function prepareCreateMany(qi, args, projection) {
920
935
  };
921
936
  }
922
937
  assertWritable(qi, 'createMany');
923
- for (const row of args.data) {
924
- assertNoGeneratedColumns(qi, row, 'createMany');
938
+ args.data.forEach((row, i) => {
939
+ if (row === null || typeof row !== 'object') {
940
+ throw new errors_js_1.ValidationError(`createMany on "${qi.table}": row ${i} is ${row === null ? 'null' : `a ${typeof row}`}, not an object of fields.`);
941
+ }
942
+ });
943
+ const generated = generatedStoredColumns(qi);
944
+ if (generated !== null) {
945
+ for (const row of args.data)
946
+ assertNoGeneratedKeys(qi, generated, row, 'createMany');
925
947
  }
926
948
  const keys = definedKeys(qi, args.data[0]);
927
949
  assertUniformCreateManyRows(qi, args.data, keys);
@@ -1117,11 +1139,13 @@ function describeConflictTargets(qi) {
1117
1139
  }
1118
1140
  /** The conflict-UPDATE's data: the field-list shorthand becomes `{ f: expr.excluded(f) }`. */
1119
1141
  function resolveConflictUpdate(qi, raw) {
1120
- const empty = new errors_js_1.ValidationError(`createMany on "${qi.table}": onConflict.update is empty; use onConflict: { target, ignore: true } to skip ` +
1142
+ // A function, not an error built up front: constructing one captures a
1143
+ // stack trace, and this runs on every onConflict call.
1144
+ const empty = () => new errors_js_1.ValidationError(`createMany on "${qi.table}": onConflict.update is empty; use onConflict: { target, ignore: true } to skip ` +
1121
1145
  'conflicting rows.');
1122
1146
  if (Array.isArray(raw)) {
1123
1147
  if (raw.length === 0)
1124
- throw empty;
1148
+ throw empty();
1125
1149
  const update = {};
1126
1150
  for (const field of raw) {
1127
1151
  if (typeof field !== 'string') {
@@ -1138,7 +1162,7 @@ function resolveConflictUpdate(qi, raw) {
1138
1162
  throw new errors_js_1.ValidationError(`createMany on "${qi.table}": onConflict.update takes a list of field names or a { field: value } object.`);
1139
1163
  }
1140
1164
  if (!Object.values(raw).some((v) => v !== undefined))
1141
- throw empty;
1165
+ throw empty();
1142
1166
  assertNoGeneratedColumns(qi, raw, 'createMany');
1143
1167
  return raw;
1144
1168
  }
@@ -1194,12 +1218,15 @@ function keyToken(value) {
1194
1218
  function assertNoDuplicateKeys(qi, conflict, columns, rowValues) {
1195
1219
  const at = conflict.target.map((c) => columns.indexOf(c));
1196
1220
  const seen = new Map();
1197
- for (let i = 0; i < rowValues.length; i++) {
1221
+ rows: for (let i = 0; i < rowValues.length; i++) {
1198
1222
  const row = rowValues[i];
1199
- const parts = at.map((j) => row[j]);
1200
- if (parts.some((v) => v === null || v === undefined))
1201
- continue;
1202
- const key = parts.map(keyToken).join('|');
1223
+ let key = '';
1224
+ for (let k = 0; k < at.length; k++) {
1225
+ const value = row[at[k]];
1226
+ if (value === null || value === undefined)
1227
+ continue rows;
1228
+ key += k === 0 ? keyToken(value) : `|${keyToken(value)}`;
1229
+ }
1203
1230
  const first = seen.get(key);
1204
1231
  if (first !== undefined) {
1205
1232
  throw new errors_js_1.ValidationError(`createMany onConflict on "${qi.table}": rows ${first} and ${i} share the conflict key ` +
@@ -2164,11 +2191,34 @@ function assertWritable(qi, operation) {
2164
2191
  * (they're stripped from the statement anyway).
2165
2192
  */
2166
2193
  function assertNoGeneratedColumns(qi, data, operation) {
2167
- for (const [key, value] of Object.entries(data)) {
2168
- if (value === undefined)
2194
+ const generated = generatedStoredColumns(qi);
2195
+ if (generated !== null)
2196
+ assertNoGeneratedKeys(qi, generated, data, operation);
2197
+ }
2198
+ /**
2199
+ * The table's generated stored columns, or `null` when it has none (most
2200
+ * tables, and then a write checks nothing). Read once per call, so
2201
+ * `createMany` checks every row against one answer: it used to search the
2202
+ * column list, with a `camelToSnake` per comparison, for every field of every
2203
+ * row, which was most of a 150-row upsert's build time.
2204
+ */
2205
+ function generatedStoredColumns(qi) {
2206
+ let columns = null;
2207
+ for (const c of qi.tableMeta.columns) {
2208
+ if (!c.isGeneratedStored)
2169
2209
  continue;
2170
- const col = qi.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === (0, schema_js_1.camelToSnake)(key));
2171
- if (col?.isGeneratedStored) {
2210
+ columns ??= new Set();
2211
+ columns.add(c.name);
2212
+ }
2213
+ return columns;
2214
+ }
2215
+ /** Refuse a key of `data` that names one of `generated` (by THE key rule, {@link resolveColumnName}). */
2216
+ function assertNoGeneratedKeys(qi, generated, data, operation) {
2217
+ for (const key in data) {
2218
+ if (!Object.hasOwn(data, key) || data[key] === undefined)
2219
+ continue;
2220
+ const column = (0, utils_js_1.resolveColumnName)(qi.tableMeta, key);
2221
+ if (column !== undefined && generated.has(column)) {
2172
2222
  throw new errors_js_1.ValidationError(`Cannot ${operation} "${qi.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
2173
2223
  'column whose value the database computes, remove it from your data.');
2174
2224
  }
package/dist/client.d.ts CHANGED
@@ -579,7 +579,9 @@ export interface TurbineConfig {
579
579
  * connection, so a statement named on one is missing (or already present) on
580
580
  * the next, unless the pooler tracks prepared statements (PgBouncer 1.21+
581
581
  * with `max_prepared_statements` above 0). Set `true` on such a pooler only
582
- * when it does; Turbine warns once in development when you do.
582
+ * when it does. Turbine warns once (`prepared-statements-on-pooler`) when you
583
+ * set `true` and the connection settings name a pooler: Turbine's own, or
584
+ * the `options` of a `pg.Pool` you pass as `pool` (since 0.90.2).
583
585
  *
584
586
  * Override with `TURBINE_DISABLE_PREPARED=1` env var.
585
587
  *
@@ -1132,6 +1134,17 @@ export declare class TurbineClient {
1132
1134
  * the target leaves unset is `PGHOST` / `PGPORT`.
1133
1135
  */
1134
1136
  private static poolerEndpoint;
1137
+ /**
1138
+ * The pooler a pool the CALLER supplied connects to, read from the settings
1139
+ * node-postgres keeps on the pool (`pool.options`, which `pg.Pool` and the
1140
+ * drivers built on it expose), the way {@link poolerEndpoint} reads Turbine's
1141
+ * own: the connection string wins over `host` / `port`, and an unset host or
1142
+ * port is `PGHOST` / `PGPORT`. `undefined` for a pool that exposes no
1143
+ * settings (another driver) or names no pooler.
1144
+ */
1145
+ private static suppliedPoolEndpoint;
1146
+ /** The first of `targets` that names a pooler, as `host:port`, after filling unset parts from the environment. */
1147
+ private static firstPooledTarget;
1135
1148
  /** Always on: named statements behind a transaction pooler fail in production first. */
1136
1149
  private static warnPreparedOnPooler;
1137
1150
  /**
package/dist/client.js CHANGED
@@ -33,6 +33,7 @@ import { CLIENT_LOG_CONTEXT, logTo, resolveLogger } from './logger.js';
33
33
  import { hasRelationFields } from './nested-write.js';
34
34
  import { ObserveEngine } from './observe.js';
35
35
  import { executePipeline, pipelineSupported } from './pipeline.js';
36
+ import { txQueryOptions } from './query/builder.js';
36
37
  import { QueryInterface, } from './query/index.js';
37
38
  import { assertNoLockInAutocommitPipeline } from './query/row-lock.js';
38
39
  import { commentDeferred, resolveSqlComment } from './query/sql-comment.js';
@@ -807,7 +808,7 @@ export class TransactionClient {
807
808
  // Create a QueryInterface that uses the transaction client as its "pool"
808
809
  // We use a proxy pool that routes queries through the transaction client
809
810
  const txPool = this.createTxPool();
810
- const txOpts = { ...this.queryOptions, _txScoped: true };
811
+ const txOpts = txQueryOptions(this.queryOptions);
811
812
  qi = txOpts.queryInterfaceFactory
812
813
  ? asQueryInterface(txOpts.queryInterfaceFactory(txPool, name, this.schema, this.middlewares, txOpts))
813
814
  : new QueryInterface(txPool, name, this.schema, this.middlewares, txOpts);
@@ -1352,8 +1353,12 @@ export class TurbineClient {
1352
1353
  // Respect env var kill switch
1353
1354
  const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
1354
1355
  const pooler = config.pool ? undefined : TurbineClient.poolerEndpoint(config);
1355
- if (config.preparedStatements === true && pooler !== undefined) {
1356
- TurbineClient.warnPreparedOnPooler(pooler, logger);
1356
+ // A supplied pool defaults to unnamed statements, so its endpoint is read
1357
+ // only to warn when they are turned on behind a pooler.
1358
+ if (config.preparedStatements === true) {
1359
+ const endpoint = config.pool ? TurbineClient.suppliedPoolEndpoint(config.pool) : pooler;
1360
+ if (endpoint !== undefined)
1361
+ TurbineClient.warnPreparedOnPooler(endpoint, logger);
1357
1362
  }
1358
1363
  this.errorMessageMode = config.errorMessages ?? 'safe';
1359
1364
  this.errorMessagesSafe = this.errorMessageMode === 'safe';
@@ -1724,6 +1729,43 @@ export class TurbineClient {
1724
1729
  if (typeof replica === 'string' && replica)
1725
1730
  targets.push(parseConnectionTarget(replica));
1726
1731
  }
1732
+ return TurbineClient.firstPooledTarget(targets);
1733
+ }
1734
+ /**
1735
+ * The pooler a pool the CALLER supplied connects to, read from the settings
1736
+ * node-postgres keeps on the pool (`pool.options`, which `pg.Pool` and the
1737
+ * drivers built on it expose), the way {@link poolerEndpoint} reads Turbine's
1738
+ * own: the connection string wins over `host` / `port`, and an unset host or
1739
+ * port is `PGHOST` / `PGPORT`. `undefined` for a pool that exposes no
1740
+ * settings (another driver) or names no pooler.
1741
+ */
1742
+ static suppliedPoolEndpoint(pool) {
1743
+ // A diagnostic: a pool whose settings cannot be read is simply not checked,
1744
+ // and nothing here may fail the constructor.
1745
+ let target;
1746
+ try {
1747
+ const options = pool.options;
1748
+ if (options === null || typeof options !== 'object')
1749
+ return undefined;
1750
+ const { connectionString, host, port } = options;
1751
+ if (typeof connectionString === 'string' && connectionString) {
1752
+ target = parseConnectionTarget(connectionString);
1753
+ }
1754
+ else {
1755
+ const portNumber = typeof port === 'string' ? Number.parseInt(port, 10) : port;
1756
+ target = {
1757
+ host: typeof host === 'string' && host ? host.toLowerCase() : null,
1758
+ port: typeof portNumber === 'number' && Number.isInteger(portNumber) ? portNumber : null,
1759
+ };
1760
+ }
1761
+ }
1762
+ catch {
1763
+ return undefined;
1764
+ }
1765
+ return TurbineClient.firstPooledTarget([target]);
1766
+ }
1767
+ /** The first of `targets` that names a pooler, as `host:port`, after filling unset parts from the environment. */
1768
+ static firstPooledTarget(targets) {
1727
1769
  const environment = { host: pgEnv('PGHOST'), port: pgEnv('PGPORT') };
1728
1770
  for (const target of targets) {
1729
1771
  const found = detectPoolerTarget(fillTargetFromEnvironment(target, environment));
@@ -168,6 +168,10 @@ export function buildGroupBy(qi, args) {
168
168
  }
169
169
  else {
170
170
  const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
171
+ // The alias below is caller text (`alias`, else the last path segment),
172
+ // written into the statement, so the statement goes unnamed: a key
173
+ // chosen per request would otherwise claim a prepared name per value.
174
+ qi.markVariableArity();
171
175
  assertAggregatePiiOptIn(qi.table, meta, entry.field, col, 'groupBy JSON `by` key', includePii);
172
176
  params.push(whereMod.jsonPathParam(qi, entry.path));
173
177
  const extract = qi.dialect.buildJsonPathExtract(qi.q(col), qi.p(params.length));
@@ -251,6 +255,8 @@ export function buildGroupBy(qi, args) {
251
255
  continue;
252
256
  }
253
257
  const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
258
+ // `key` is a caller-chosen label written into the alias: unnamed, as above.
259
+ qi.markVariableArity();
254
260
  if (aggKey === '_min' || aggKey === '_max') {
255
261
  assertAggregatePiiOptIn(qi.table, meta, target.field, col, `groupBy ${aggKey} JSON target`, includePii);
256
262
  }
@@ -177,6 +177,7 @@ export declare const AUTO_COUNT_BATCH_MIN_PARENT_ROWS = 2;
177
177
  export declare function unlockNestedWriteTx<X extends {
178
178
  table(name: string): Record<string, unknown>;
179
179
  }>(tx: X): X;
180
+ export declare function txQueryOptions(options: QueryInterfaceOptions | undefined): QueryInterfaceOptions;
180
181
  export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, TemporalInfinityReading, } from './deferred.js';
181
182
  import type { DeferredQuery, MiddlewareFn, QueryInterfaceOptions } from './deferred.js';
182
183
  export declare class QueryInterface<T extends object, R extends object = {}, M extends TableInputTypes = DefaultInputTypes<T, R>> {
@@ -904,8 +905,11 @@ export declare class QueryInterface<T extends object, R extends object = {}, M e
904
905
  */
905
906
  private prepareBatchedBase;
906
907
  /**
907
- * Return cache hit/miss statistics for this QueryInterface instance.
908
- * Useful for monitoring and benchmarking.
908
+ * SQL template cache statistics. `hits`, `misses` and `hitRate` count this
909
+ * accessor's own lookups. `size` is the entries in the cache it reads, which
910
+ * since 0.90.2 is shared with every accessor built from the same client
911
+ * options for this table: a client's primary and replica accessors share
912
+ * one, and all of its transactions share another (see `templateCacheFor`).
909
913
  */
910
914
  cacheStats(): {
911
915
  hits: number;
@@ -951,6 +955,19 @@ export declare class QueryInterface<T extends object, R extends object = {}, M e
951
955
  * variable-arity and the measured reason it must not be named.
952
956
  */
953
957
  private buildCacheEntry;
958
+ /**
959
+ * The prepared-statement name for `sql`: empty (sent unnamed) for a
960
+ * variable-arity shape, else derived from the text.
961
+ */
962
+ private preparedNameOf;
963
+ /**
964
+ * Name a statement the SQL template cache does not hold (`aggregate`,
965
+ * `groupBy`): the build runs between a reset and a read of the
966
+ * variable-arity flag, exactly as in {@link buildCacheEntry}, so the same
967
+ * shapes stay unnamed. These used to be sent unnamed always, so with
968
+ * prepared statements on the server re-parsed every `aggregate`.
969
+ */
970
+ private withPreparedName;
954
971
  /**
955
972
  * The SQL-cache lockstep checks (see the comment above `CACHE_MISMATCH_WARN_NS`).
956
973
  *
@@ -502,6 +502,69 @@ const JSON_ENCODINGS = Object.freeze(['object', 'positional']);
502
502
  function defaultJsonEncoding(dialect) {
503
503
  return dialect.capabilities.positionalJson ? 'positional' : 'object';
504
504
  }
505
+ /**
506
+ * SQL template caches shared by every QueryInterface built from ONE options
507
+ * object, for one schema and one table.
508
+ *
509
+ * A transaction builds its own QueryInterface per table, so before this each
510
+ * `$transaction` started with an empty cache and rebuilt the SQL of every
511
+ * statement it sent. TransactionClient now hands every transaction of a client
512
+ * the same options object (`txQueryOptions` in client.ts), so they share one
513
+ * cache per table and a statement is built once per process, as it is outside
514
+ * a transaction.
515
+ *
516
+ * THE KEY IS THE OPTIONS OBJECT'S IDENTITY, deliberately, not a fingerprint of
517
+ * its fields. Options change the SQL (`defaultLimit`, `jsonEncoding`,
518
+ * `globalFilters`, the dialect, ...), and a cache key does not carry them,
519
+ * because one QueryInterface never sees two values. A copy that changes any
520
+ * field is a new object and so gets its own caches: the batched loader's
521
+ * children (`defaultLimit: undefined`) can never be served the parent's SQL,
522
+ * and no list of SQL-affecting options exists to fall out of date. Nothing
523
+ * mutates an options object after construction.
524
+ */
525
+ const sharedTemplateCaches = new WeakMap();
526
+ /**
527
+ * The options a transaction's query interfaces are built with: `options` plus
528
+ * `_txScoped`. ONE object per options object, so every transaction of a client
529
+ * (and every nested write inside one) builds its query interfaces from the same
530
+ * object and shares their template caches (see {@link templateCacheFor}). A
531
+ * fresh copy per transaction gave each `$transaction` an empty cache, so every
532
+ * statement in it rebuilt its SQL. Options that are already transaction-scoped
533
+ * are returned as they are.
534
+ */
535
+ const txOptionsCache = new WeakMap();
536
+ export function txQueryOptions(options) {
537
+ if (options === undefined)
538
+ return { _txScoped: true };
539
+ if (options._txScoped === true)
540
+ return options;
541
+ let tx = txOptionsCache.get(options);
542
+ if (tx === undefined) {
543
+ tx = { ...options, _txScoped: true };
544
+ txOptionsCache.set(options, tx);
545
+ }
546
+ return tx;
547
+ }
548
+ function templateCacheFor(options, schema, table, capacity) {
549
+ if (options === undefined)
550
+ return new LRUCache(capacity);
551
+ let bySchema = sharedTemplateCaches.get(options);
552
+ if (bySchema === undefined) {
553
+ bySchema = new WeakMap();
554
+ sharedTemplateCaches.set(options, bySchema);
555
+ }
556
+ let byTable = bySchema.get(schema);
557
+ if (byTable === undefined) {
558
+ byTable = new Map();
559
+ bySchema.set(schema, byTable);
560
+ }
561
+ let cache = byTable.get(table);
562
+ if (cache === undefined) {
563
+ cache = new LRUCache(capacity);
564
+ byTable.set(table, cache);
565
+ }
566
+ return cache;
567
+ }
505
568
  export class QueryInterface {
506
569
  pool;
507
570
  table;
@@ -795,7 +858,7 @@ export class QueryInterface {
795
858
  // treated as the default rather than throwing.
796
859
  const sqlCacheSize = options?.sqlCacheSize;
797
860
  this.sqlCacheEnabled = options?.sqlCache !== false && sqlCacheSize !== 0;
798
- this.sqlTemplateCache = new LRUCache(sqlCacheSize !== undefined && sqlCacheSize > 0 ? Math.floor(sqlCacheSize) : 1000);
861
+ this.sqlTemplateCache = templateCacheFor(options, schema, table, sqlCacheSize !== undefined && sqlCacheSize > 0 ? Math.floor(sqlCacheSize) : 1000);
799
862
  this.dialect = options?.dialect ?? postgresDialect;
800
863
  assertDialectCapabilities(this.dialect);
801
864
  this.sqlCommenter =
@@ -2184,8 +2247,11 @@ export class QueryInterface {
2184
2247
  return { baseArgs, strip: proj.strip };
2185
2248
  }
2186
2249
  /**
2187
- * Return cache hit/miss statistics for this QueryInterface instance.
2188
- * Useful for monitoring and benchmarking.
2250
+ * SQL template cache statistics. `hits`, `misses` and `hitRate` count this
2251
+ * accessor's own lookups. `size` is the entries in the cache it reads, which
2252
+ * since 0.90.2 is shared with every accessor built from the same client
2253
+ * options for this table: a client's primary and replica accessors share
2254
+ * one, and all of its transactions share another (see `templateCacheFor`).
2189
2255
  */
2190
2256
  cacheStats() {
2191
2257
  const total = this.cacheHits + this.cacheMisses;
@@ -2258,15 +2324,33 @@ export class QueryInterface {
2258
2324
  this.variableArityShape = false;
2259
2325
  this.cacheMisses++;
2260
2326
  this.lastMissParams = built;
2327
+ return { sql, name: this.preparedNameOf(sql, variableArity), paramCount: built.length };
2328
+ }
2329
+ /**
2330
+ * The prepared-statement name for `sql`: empty (sent unnamed) for a
2331
+ * variable-arity shape, else derived from the text.
2332
+ */
2333
+ preparedNameOf(sql, variableArity) {
2334
+ if (variableArity)
2335
+ return '';
2261
2336
  // With `sqlComment` putting a comment on every statement, the template's own
2262
2337
  // name is never sent (the execute seam sends a `c_` name derived from it),
2263
2338
  // so it is not claimed from the budget: only names that reach the wire do.
2264
- const name = variableArity
2265
- ? ''
2266
- : this.sqlCommenter?.alwaysComments
2267
- ? sqlToPreparedName(sql)
2268
- : boundedPreparedName(sql);
2269
- return { sql, name, paramCount: built.length };
2339
+ return this.sqlCommenter?.alwaysComments ? sqlToPreparedName(sql) : boundedPreparedName(sql);
2340
+ }
2341
+ /**
2342
+ * Name a statement the SQL template cache does not hold (`aggregate`,
2343
+ * `groupBy`): the build runs between a reset and a read of the
2344
+ * variable-arity flag, exactly as in {@link buildCacheEntry}, so the same
2345
+ * shapes stay unnamed. These used to be sent unnamed always, so with
2346
+ * prepared statements on the server re-parsed every `aggregate`.
2347
+ */
2348
+ withPreparedName(build) {
2349
+ this.variableArityShape = false;
2350
+ const deferred = build();
2351
+ const variableArity = this.variableArityShape;
2352
+ this.variableArityShape = false;
2353
+ return { ...deferred, preparedName: this.preparedNameOf(deferred.sql, variableArity) };
2270
2354
  }
2271
2355
  /**
2272
2356
  * The SQL-cache lockstep checks (see the comment above `CACHE_MISMATCH_WARN_NS`).
@@ -4227,6 +4311,8 @@ export class QueryInterface {
4227
4311
  return rows.length > 0 ? rows[0] : null;
4228
4312
  },
4229
4313
  tag: `${this.table}.findFirst`,
4314
+ // findMany's name: the same statement, so the same prepared statement.
4315
+ ...(deferred.preparedName !== undefined ? { preparedName: deferred.preparedName } : {}),
4230
4316
  ...(deferred.rowLock ? { rowLock: true } : {}),
4231
4317
  };
4232
4318
  }
@@ -4260,6 +4346,7 @@ export class QueryInterface {
4260
4346
  return row;
4261
4347
  },
4262
4348
  tag: `${this.table}.findFirstOrThrow`,
4349
+ ...(inner.preparedName !== undefined ? { preparedName: inner.preparedName } : {}),
4263
4350
  ...(inner.rowLock ? { rowLock: true } : {}),
4264
4351
  };
4265
4352
  }
@@ -4293,6 +4380,7 @@ export class QueryInterface {
4293
4380
  return row;
4294
4381
  },
4295
4382
  tag: `${this.table}.findUniqueOrThrow`,
4383
+ ...(inner.preparedName !== undefined ? { preparedName: inner.preparedName } : {}),
4296
4384
  ...(inner.rowLock ? { rowLock: true } : {}),
4297
4385
  };
4298
4386
  }
@@ -4462,7 +4550,10 @@ export class QueryInterface {
4462
4550
  const pool = this.pool;
4463
4551
  const schema = this.schema;
4464
4552
  const middlewares = this.middlewares;
4465
- const opts = { ...this.options, _txScoped: true };
4553
+ // The client's transaction options object, not a copy per call: the SQL
4554
+ // template cache is shared by options-object identity, so a fresh copy
4555
+ // rebuilt the SQL of every child statement of every nested write.
4556
+ const opts = txQueryOptions(this.options);
4466
4557
  return {
4467
4558
  schema,
4468
4559
  tx: unlockNestedWriteTx(this.makeTxProxy(pool, schema, middlewares, opts)),
@@ -4653,10 +4744,12 @@ export class QueryInterface {
4653
4744
  // Aggregate / groupBy compilation (extracted to aggregates.ts).
4654
4745
  // ---------------------------------------------------------------------------
4655
4746
  buildGroupBy(args) {
4656
- return aggMod.buildGroupBy(this.ctx, this.normalizeArgs(args));
4747
+ const normalized = this.normalizeArgs(args);
4748
+ return this.withPreparedName(() => aggMod.buildGroupBy(this.ctx, normalized));
4657
4749
  }
4658
4750
  buildAggregate(args) {
4659
- return aggMod.buildAggregate(this.ctx, this.normalizeArgs(args));
4751
+ const normalized = this.normalizeArgs(args);
4752
+ return this.withPreparedName(() => aggMod.buildAggregate(this.ctx, normalized));
4660
4753
  }
4661
4754
  // -------------------------------------------------------------------------
4662
4755
  // aggregate, standalone aggregation without groupBy
@@ -12,7 +12,6 @@
12
12
  */
13
13
  import { maxBindParameters, } from '../dialect.js';
14
14
  import { NotFoundError, OptimisticLockError, UnsupportedFeatureError, ValidationError } from '../errors.js';
15
- import { camelToSnake } from '../schema.js';
16
15
  import { isColumnRef } from './column-ref.js';
17
16
  import { assertMutationWhereIdentifiesOneRow, expandCompoundUniqueWhere, otherCollidingUniqueKey, uniqueKeyNames, upsertCreateCollidingKey, upsertWhereMatchesCreate, } from './compound-unique.js';
18
17
  import { compileSetExpr, expr as exprBuilders, exprNotAllowedMessage, isPlainExprShape, isSqlExpr, plainExprShapeMessage, resolveExcludedFromCreate, } from './expr.js';
@@ -755,6 +754,10 @@ function definedKeys(qi, row) {
755
754
  function assertUniformCreateManyRows(qi, rows, firstKeys) {
756
755
  const expected = new Set(firstKeys);
757
756
  for (let i = 1; i < rows.length; i++) {
757
+ // The common case, decided without ordering the row's keys: as many
758
+ // defined keys as the first row, each one of its keys.
759
+ if (namesExactly(rows[i], expected))
760
+ continue;
758
761
  const rowKeys = definedKeys(qi, rows[i]);
759
762
  const unexpected = rowKeys.filter((k) => !expected.has(k));
760
763
  // No stranger and the same count means the same set (object keys are unique).
@@ -775,6 +778,18 @@ function assertUniformCreateManyRows(qi, rows, firstKeys) {
775
778
  'row shape.');
776
779
  }
777
780
  }
781
+ /** Whether `row`'s defined keys are exactly `keys` (object keys are unique, so count plus membership decides it). */
782
+ function namesExactly(row, keys) {
783
+ let count = 0;
784
+ for (const key in row) {
785
+ if (!Object.hasOwn(row, key) || row[key] === undefined)
786
+ continue;
787
+ if (!keys.has(key))
788
+ return false;
789
+ count++;
790
+ }
791
+ return count === keys.size;
792
+ }
778
793
  /** `a, b` → `"a", "b"`, for the field lists in {@link assertUniformCreateManyRows}. */
779
794
  function quoteList(fields) {
780
795
  return fields.map((f) => `"${f}"`).join(', ');
@@ -857,8 +872,15 @@ function prepareCreateMany(qi, args, projection) {
857
872
  };
858
873
  }
859
874
  assertWritable(qi, 'createMany');
860
- for (const row of args.data) {
861
- assertNoGeneratedColumns(qi, row, 'createMany');
875
+ args.data.forEach((row, i) => {
876
+ if (row === null || typeof row !== 'object') {
877
+ throw new ValidationError(`createMany on "${qi.table}": row ${i} is ${row === null ? 'null' : `a ${typeof row}`}, not an object of fields.`);
878
+ }
879
+ });
880
+ const generated = generatedStoredColumns(qi);
881
+ if (generated !== null) {
882
+ for (const row of args.data)
883
+ assertNoGeneratedKeys(qi, generated, row, 'createMany');
862
884
  }
863
885
  const keys = definedKeys(qi, args.data[0]);
864
886
  assertUniformCreateManyRows(qi, args.data, keys);
@@ -1054,11 +1076,13 @@ function describeConflictTargets(qi) {
1054
1076
  }
1055
1077
  /** The conflict-UPDATE's data: the field-list shorthand becomes `{ f: expr.excluded(f) }`. */
1056
1078
  function resolveConflictUpdate(qi, raw) {
1057
- const empty = new ValidationError(`createMany on "${qi.table}": onConflict.update is empty; use onConflict: { target, ignore: true } to skip ` +
1079
+ // A function, not an error built up front: constructing one captures a
1080
+ // stack trace, and this runs on every onConflict call.
1081
+ const empty = () => new ValidationError(`createMany on "${qi.table}": onConflict.update is empty; use onConflict: { target, ignore: true } to skip ` +
1058
1082
  'conflicting rows.');
1059
1083
  if (Array.isArray(raw)) {
1060
1084
  if (raw.length === 0)
1061
- throw empty;
1085
+ throw empty();
1062
1086
  const update = {};
1063
1087
  for (const field of raw) {
1064
1088
  if (typeof field !== 'string') {
@@ -1075,7 +1099,7 @@ function resolveConflictUpdate(qi, raw) {
1075
1099
  throw new ValidationError(`createMany on "${qi.table}": onConflict.update takes a list of field names or a { field: value } object.`);
1076
1100
  }
1077
1101
  if (!Object.values(raw).some((v) => v !== undefined))
1078
- throw empty;
1102
+ throw empty();
1079
1103
  assertNoGeneratedColumns(qi, raw, 'createMany');
1080
1104
  return raw;
1081
1105
  }
@@ -1131,12 +1155,15 @@ function keyToken(value) {
1131
1155
  function assertNoDuplicateKeys(qi, conflict, columns, rowValues) {
1132
1156
  const at = conflict.target.map((c) => columns.indexOf(c));
1133
1157
  const seen = new Map();
1134
- for (let i = 0; i < rowValues.length; i++) {
1158
+ rows: for (let i = 0; i < rowValues.length; i++) {
1135
1159
  const row = rowValues[i];
1136
- const parts = at.map((j) => row[j]);
1137
- if (parts.some((v) => v === null || v === undefined))
1138
- continue;
1139
- const key = parts.map(keyToken).join('|');
1160
+ let key = '';
1161
+ for (let k = 0; k < at.length; k++) {
1162
+ const value = row[at[k]];
1163
+ if (value === null || value === undefined)
1164
+ continue rows;
1165
+ key += k === 0 ? keyToken(value) : `|${keyToken(value)}`;
1166
+ }
1140
1167
  const first = seen.get(key);
1141
1168
  if (first !== undefined) {
1142
1169
  throw new ValidationError(`createMany onConflict on "${qi.table}": rows ${first} and ${i} share the conflict key ` +
@@ -2101,11 +2128,34 @@ export function assertWritable(qi, operation) {
2101
2128
  * (they're stripped from the statement anyway).
2102
2129
  */
2103
2130
  export function assertNoGeneratedColumns(qi, data, operation) {
2104
- for (const [key, value] of Object.entries(data)) {
2105
- if (value === undefined)
2131
+ const generated = generatedStoredColumns(qi);
2132
+ if (generated !== null)
2133
+ assertNoGeneratedKeys(qi, generated, data, operation);
2134
+ }
2135
+ /**
2136
+ * The table's generated stored columns, or `null` when it has none (most
2137
+ * tables, and then a write checks nothing). Read once per call, so
2138
+ * `createMany` checks every row against one answer: it used to search the
2139
+ * column list, with a `camelToSnake` per comparison, for every field of every
2140
+ * row, which was most of a 150-row upsert's build time.
2141
+ */
2142
+ function generatedStoredColumns(qi) {
2143
+ let columns = null;
2144
+ for (const c of qi.tableMeta.columns) {
2145
+ if (!c.isGeneratedStored)
2106
2146
  continue;
2107
- const col = qi.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === camelToSnake(key));
2108
- if (col?.isGeneratedStored) {
2147
+ columns ??= new Set();
2148
+ columns.add(c.name);
2149
+ }
2150
+ return columns;
2151
+ }
2152
+ /** Refuse a key of `data` that names one of `generated` (by THE key rule, {@link resolveColumnName}). */
2153
+ function assertNoGeneratedKeys(qi, generated, data, operation) {
2154
+ for (const key in data) {
2155
+ if (!Object.hasOwn(data, key) || data[key] === undefined)
2156
+ continue;
2157
+ const column = resolveColumnName(qi.tableMeta, key);
2158
+ if (column !== undefined && generated.has(column)) {
2109
2159
  throw new ValidationError(`Cannot ${operation} "${qi.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
2110
2160
  'column whose value the database computes, remove it from your data.');
2111
2161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.90.1",
3
+ "version": "0.90.2-next.91484a2",
4
4
  "description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",