turbine-orm 0.55.0 → 0.56.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.
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Plan-divergence advisor, the third question doctor asks about a probe column.
3
+ *
4
+ * `index-advisor.ts` answers "which relation probes have no index" from pure
5
+ * topology. `index-stats.ts` answers "is adding that index worth it" from live
6
+ * statistics. This module answers the third question on the SAME columns: "this
7
+ * column IS indexed, and its value distribution makes a NAMED prepared
8
+ * statement's generic plan unsafe".
9
+ *
10
+ * THE MECHANISM. Postgres may promote a named prepared statement to a GENERIC
11
+ * plan from its sixth execution onward, and ONLY when the generic plan's
12
+ * estimated cost is not worse than the average custom cost. A generic plan
13
+ * cannot see any parameter value, so it substitutes a default for each one:
14
+ *
15
+ * - an unknown equality `col = $1` is estimated as reltuples / n_distinct;
16
+ * - an unknown `LIMIT $n` is estimated as 10% of the CHILD node's row estimate.
17
+ *
18
+ * THE ONE SHAPE THIS MODELS, stated narrowly on purpose. A read shaped
19
+ * `WHERE col = $1 ORDER BY <other indexed column> LIMIT $n`, where the generic
20
+ * estimate for `col = $1` sits ABOVE the plan boundary (so the generic plan
21
+ * keeps the ordered index scan and filters) while some real values sit far
22
+ * BELOW it (so for those values the ordered scan has to walk a large fraction
23
+ * of the table before it accumulates one page of matches, where the custom
24
+ * plan takes a bitmap scan over the value's own rows and sorts them).
25
+ *
26
+ * WHAT THIS DELIBERATELY DOES NOT MODEL, because it was tried and it did not
27
+ * work. An earlier revision carried a second rule for the opposite direction (a
28
+ * physically clustered column whose densest value is far ABOVE the generic
29
+ * estimate). Measured against live fixtures it was wrong more often than right,
30
+ * and twice it was wrong with the SIGN INVERTED: it predicted "at least 16,032x"
31
+ * and "at least 2,675x" on columns where the generic plan was in fact 10x and
32
+ * 105x BETTER than the custom one, so acting on the finding would have made
33
+ * those reads dramatically slower. The reason is structural, not a bad constant:
34
+ * whether that flip helps or hurts turns on WHERE in the heap the value's rows
35
+ * physically sit, and no pg_stats input carries that. `correlation` is a
36
+ * whole-column property and is identical whether the dominant band is at the
37
+ * head of the heap or its tail. The rule was removed rather than retuned.
38
+ *
39
+ * The same blind spot bounds what remains: this check can see how MANY rows a
40
+ * value has, not WHERE they are, so it under-reports (a rare value packed at the
41
+ * end of the heap is worse than modelled) and it cannot see the third-party
42
+ * shapes where a generic plan is the better one. A clean report is not evidence
43
+ * of immunity.
44
+ *
45
+ * EXPOSURE IS NOT AN INCIDENT. A finding says the DISTRIBUTION admits a
46
+ * damaging flip. It does NOT say the backend is choosing the bad plan today:
47
+ * `auto` promotes only when the generic plan's ESTIMATED cost is not worse than
48
+ * the average custom cost, and on a shape whose generic plan is estimated
49
+ * expensive it never promotes at all. That is why every finding ships a
50
+ * diagnostic that checks `pg_prepared_statements.generic_plans` BEFORE it
51
+ * compares the two plans: the counter is the only thing that establishes real
52
+ * exposure.
53
+ *
54
+ * TWO UNITS, and mixing them up is what made the removed rule wrong. The
55
+ * PLANNER costs the boundary in PAGES (an ordered index scan reads about
56
+ * limit / matching of the table's pages before it fills the limit), which is why
57
+ * {@link crossoverRows} is derived from relpages and why measured flip points
58
+ * track it. The DAMAGE a user feels is also read in pages here, deliberately
59
+ * conservatively; when the ordering index is not correlated with heap order,
60
+ * each row examined is a separate block access and the true buffer count is
61
+ * larger, sometimes by an order of magnitude.
62
+ *
63
+ * FRESHNESS GATE. The cost-tier half of doctor gates on `stats_reset` age,
64
+ * because it normalizes WRITE COUNTERS by it. Nothing here reads a counter: every
65
+ * input comes from pg_stats, which is refreshed by ANALYZE. This check therefore
66
+ * gates on ANALYZE freshness instead (a column with no pg_stats row, or an
67
+ * n_distinct of 0, is suppressed with a notice, and every finding carries the
68
+ * table's last_analyze). Reusing the counter gate would have silenced the check
69
+ * on every cluster whose `pg_stat_database.stats_reset` is NULL, which is the
70
+ * default state and has nothing to do with whether ANALYZE has ever run.
71
+ *
72
+ * Postgres-only (pg_stats + plan_cache_mode). Pure: no pg import, no EXPLAIN.
73
+ */
74
+ import { EXPRESSION_COLUMN } from './index-stats.js';
75
+ import { quoteIdent } from './query/utils.js';
76
+ // ---------------------------------------------------------------------------
77
+ // Thresholds
78
+ // ---------------------------------------------------------------------------
79
+ /**
80
+ * Gates behind a plan-divergence finding. Exported and printed with every
81
+ * finding so the reasoning is never a black box.
82
+ *
83
+ * Each one is either derived from the cost model and then checked against
84
+ * measurement, or fitted to a measured flip boundary; the comments record which.
85
+ */
86
+ export const PLAN_DIVERGENCE_THRESHOLDS = {
87
+ /**
88
+ * The LIMIT the advisor assumes, because it cannot see the application's.
89
+ *
90
+ * It is NOT a conservative bound in either direction, and an earlier comment
91
+ * here claimed it was. The crossover grows as sqrt(limit), so raising the
92
+ * limit moves BOTH gates: `rarestBucket < crossover` gets easier to satisfy
93
+ * and `genericEstimate >= crossover` gets harder, and the second one can turn
94
+ * a finding off. 20 is simply a common first page. Every finding also reports
95
+ * the crossover at {@link wideLimit} so a caller paginating in thousands can
96
+ * see where their own limit lands.
97
+ */
98
+ assumedLimit: 20,
99
+ /** A second crossover reported alongside, for callers paginating in thousands. */
100
+ wideLimit: 1_000,
101
+ /**
102
+ * How many of the table's pages the WRONG plan must walk before this is worth
103
+ * a user's attention: ~400 KB of heap. Below it the flip cannot cost more than
104
+ * a millisecond or two however bad the ratio looks.
105
+ *
106
+ * This replaced a `relpages >= 1000` table-size floor, which was wrong and
107
+ * measurably so: a wrong plan on a SMALL table is not cheap, because an ordered
108
+ * index scan's cost is driven by how much of the table it walks, not by how big
109
+ * the table is. On a 285-page / 18,500-row fixture the generic plan read 336
110
+ * buffers against the custom plan's 7 (48x), and the old floor dropped that
111
+ * column without even counting it as considered.
112
+ */
113
+ minWalkPages: 50,
114
+ /**
115
+ * ...and it must walk at least this FRACTION of the table. The two gates are
116
+ * different questions (absolute cost, and how badly the plan is mismatched to
117
+ * the value), and a finding needs both.
118
+ */
119
+ minWalkFraction: 0.1,
120
+ };
121
+ // ---------------------------------------------------------------------------
122
+ // Candidate enumeration (schema-only)
123
+ // ---------------------------------------------------------------------------
124
+ /** True when `column` is unique on its own, so `col = $1` always matches one row. */
125
+ function isUniqueOnColumn(meta, column) {
126
+ if (meta.primaryKey.length === 1 && meta.primaryKey[0] === column)
127
+ return true;
128
+ return meta.uniqueColumns.some((cols) => cols.length === 1 && cols[0] === column);
129
+ }
130
+ /**
131
+ * Every column whose value distribution is worth reading: a column Turbine
132
+ * probes by equality (a relation FK) or that the schema already indexes as a
133
+ * leading key, minus the columns that are unique on their own (a unique
134
+ * equality matches one row, so both plans agree).
135
+ *
136
+ * Purely schema-derived, so the collector knows what to read BEFORE any stats
137
+ * exist. Whether the column is really served by a btree is decided later,
138
+ * against the live index list.
139
+ */
140
+ export function collectDivergenceCandidateColumns(schema) {
141
+ const seen = new Set();
142
+ const out = [];
143
+ const add = (table, column) => {
144
+ const meta = schema.tables[table];
145
+ if (!meta)
146
+ return;
147
+ if (!meta.allColumns.includes(column))
148
+ return;
149
+ if (isUniqueOnColumn(meta, column))
150
+ return;
151
+ const key = `${table}.${column}`;
152
+ if (seen.has(key))
153
+ return;
154
+ seen.add(key);
155
+ out.push({ table, column });
156
+ };
157
+ for (const meta of Object.values(schema.tables)) {
158
+ // Relation probe columns: the equality predicates Turbine itself emits.
159
+ for (const relDef of Object.values(meta.relations)) {
160
+ const target = schema.tables[relDef.to];
161
+ if (!target)
162
+ continue;
163
+ const keys = relDef.type === 'belongsTo' ? relDef.referenceKey : relDef.foreignKey;
164
+ const cols = Array.isArray(keys) ? keys : [keys];
165
+ // Only a SINGLE-column probe has a meaningful single-column distribution;
166
+ // a composite predicate multiplies selectivities and is not modelled here.
167
+ if (cols.length === 1 && cols[0] !== undefined)
168
+ add(relDef.to, cols[0]);
169
+ if (relDef.type === 'manyToMany' && relDef.through) {
170
+ const src = Array.isArray(relDef.through.sourceKey) ? relDef.through.sourceKey : [relDef.through.sourceKey];
171
+ if (src.length === 1 && src[0] !== undefined)
172
+ add(relDef.through.table, src[0]);
173
+ }
174
+ }
175
+ // Leading index columns: whatever the application already filters on.
176
+ for (const idx of meta.indexes) {
177
+ if (idx.docPath)
178
+ continue;
179
+ const lead = idx.columns[0];
180
+ if (lead !== undefined && lead !== EXPRESSION_COLUMN)
181
+ add(meta.name, lead);
182
+ }
183
+ }
184
+ return out.sort((a, b) => a.table.localeCompare(b.table) || a.column.localeCompare(b.column));
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Live index shape
188
+ // ---------------------------------------------------------------------------
189
+ /** A plain, valid btree with no expression column and no partial predicate. */
190
+ function isPlainBtree(idx) {
191
+ if (!idx.isValid)
192
+ return false;
193
+ if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
194
+ return false;
195
+ if (idx.hasExpressions === true)
196
+ return false;
197
+ if (idx.columns.includes(EXPRESSION_COLUMN))
198
+ return false;
199
+ return idx.predicate == null;
200
+ }
201
+ /**
202
+ * Whether a plain btree LEADS with `column` (so the planner has an index path
203
+ * for `column = $1`), and whether the table also has a DIFFERENT ordering index
204
+ * the generic plan can run away with (in practice the primary key).
205
+ *
206
+ * Both halves matter. With NO index on the filter column the two plans are
207
+ * identical and equally bad, which is the EXISTING missing-index finding, not
208
+ * this one. With no alternative ordering index there is no other plan to flip to.
209
+ */
210
+ function indexShapeFor(snapshot, meta, column) {
211
+ let hasIdx = false;
212
+ let orderColumn = null;
213
+ const pk = meta.primaryKey.length > 0 ? meta.primaryKey[0] : undefined;
214
+ for (const idx of snapshot.indexes) {
215
+ if (idx.table !== meta.name)
216
+ continue;
217
+ if (!isPlainBtree(idx))
218
+ continue;
219
+ const lead = idx.columns[0];
220
+ if (lead === undefined)
221
+ continue;
222
+ if (lead === column) {
223
+ hasIdx = true;
224
+ continue;
225
+ }
226
+ // Prefer the primary key as the stated ordering column: it is the one a
227
+ // paginated read almost always orders by, and it is the plan the generic
228
+ // estimate actually chose in every measured case.
229
+ if (orderColumn === null || lead === pk)
230
+ orderColumn = lead;
231
+ }
232
+ return { hasIdx, orderColumn };
233
+ }
234
+ // ---------------------------------------------------------------------------
235
+ // Detection
236
+ // ---------------------------------------------------------------------------
237
+ /**
238
+ * The plan-boundary crossover: below this many truly matching rows, running
239
+ * `ORDER BY <other column> LIMIT n` as bitmap-scan + sort is cheaper than
240
+ * walking the ordering index and filtering.
241
+ *
242
+ * Derivation: an ordered index scan expects to read about (limit / matching) x
243
+ * pages before it accumulates `limit` matches; a bitmap scan reads about
244
+ * min(matching, pages). Those are equal at matching = sqrt(limit x pages).
245
+ * Measured flip points tracked this within 13 to 26% across two orders of
246
+ * magnitude of limit, which is why the size input is relpages rather than a
247
+ * row count.
248
+ *
249
+ * PREMISE, and it is the same blind spot the whole check has: both halves
250
+ * assume the matching rows are SCATTERED through the heap. On a clustered
251
+ * column a bitmap scan reads far fewer than min(matching, pages), so there is
252
+ * no flip at any limit and this crossover does not describe the table at all.
253
+ * pg_stats.correlation hints at it but does not settle it (a column can be
254
+ * uncorrelated overall and still have one value packed in the tail, which is
255
+ * exactly the counterexample fixture). Treat a crossover as a reason to run
256
+ * the diagnostic block, never as a measurement.
257
+ */
258
+ export function crossoverRows(pages, limit) {
259
+ return Math.sqrt(Math.max(0, limit) * Math.max(0, pages));
260
+ }
261
+ /** Decode pg_stats.n_distinct: negative values are a fraction of the row count. */
262
+ function decodeDistinct(nDistinct, rows) {
263
+ return nDistinct > 0 ? nDistinct : -nDistinct * rows;
264
+ }
265
+ /**
266
+ * The check that settles a finding. Three steps, in the order that makes them
267
+ * mean something:
268
+ *
269
+ * 1. Does `auto` actually promote this shape? `pg_prepared_statements`
270
+ * reports `generic_plans` per cached statement, and until that counter
271
+ * leaves 0 the backend is planning with the real values and there is
272
+ * nothing to fix. This step comes FIRST because a finding describes
273
+ * exposure, not an incident, and on many shapes the generic plan is
274
+ * estimated expensive enough that `auto` never promotes at all.
275
+ * 2. What would the promoted plan cost? The two `plan_cache_mode` settings
276
+ * make both plans reachable on demand.
277
+ * 3. Put the session back, so the paste does not leave `plan_cache_mode`
278
+ * pinned for everything the user does next.
279
+ */
280
+ function buildDiagnosticSql(table, column, orderColumn, columnType) {
281
+ const t = quoteIdent(table);
282
+ const c = quoteIdent(column);
283
+ const o = quoteIdent(orderColumn);
284
+ return [
285
+ 'SET synchronize_seqscans = off;',
286
+ 'SET max_parallel_workers_per_gather = 0;',
287
+ `PREPARE turbine_divergence(${columnType}, int) AS`,
288
+ ` SELECT * FROM ${t} WHERE ${c} = $1 ORDER BY ${o} LIMIT $2;`,
289
+ '-- 1. does this shape get promoted at all? run it six times, then look:',
290
+ 'EXECUTE turbine_divergence(<your value>, 20); -- x6',
291
+ "SELECT generic_plans, custom_plans FROM pg_prepared_statements WHERE name = 'turbine_divergence';",
292
+ '-- generic_plans still 0 means the planner is refusing the generic plan: no exposure.',
293
+ '-- 2. what the promoted plan would cost:',
294
+ 'SET plan_cache_mode = force_custom_plan;',
295
+ 'EXPLAIN (ANALYZE, BUFFERS) EXECUTE turbine_divergence(<your value>, 20);',
296
+ 'SET plan_cache_mode = force_generic_plan;',
297
+ 'EXPLAIN (ANALYZE, BUFFERS) EXECUTE turbine_divergence(<your value>, 20);',
298
+ '-- 3. put the session back:',
299
+ 'RESET plan_cache_mode; RESET synchronize_seqscans; RESET max_parallel_workers_per_gather;',
300
+ 'DEALLOCATE turbine_divergence;',
301
+ ].join('\n');
302
+ }
303
+ /**
304
+ * Score every candidate column against the snapshot and return the ones whose
305
+ * distribution admits a damaging generic-plan flip.
306
+ *
307
+ * ONE rule, for the direction that could be calibrated: the generic estimate
308
+ * sits ABOVE the plan boundary (so a promoted plan keeps the ordered index
309
+ * scan) while the table's rarest values sit below it, and the pages that
310
+ * ordered scan must walk for such a value are worth a user's attention both in
311
+ * absolute terms and as a fraction of the table.
312
+ *
313
+ * `correlation` is reported but no longer gates anything. It used to route
314
+ * clustered columns to a second rule; that rule is gone (see the module header),
315
+ * and routing on it also made the advisor STRUCTURALLY unable to report a real
316
+ * sparse-direction flip on any column that happened to be clustered, which was
317
+ * measured at 1,165x on one fixture.
318
+ */
319
+ export function findPlanDivergence(schema, snapshot) {
320
+ const t = PLAN_DIVERGENCE_THRESHOLDS;
321
+ const findings = [];
322
+ const notices = [];
323
+ let candidatesConsidered = 0;
324
+ for (const candidate of collectDivergenceCandidateColumns(schema)) {
325
+ const meta = schema.tables[candidate.table];
326
+ if (!meta)
327
+ continue;
328
+ const stats = snapshot.tables[candidate.table];
329
+ if (!stats)
330
+ continue;
331
+ const rows = stats.reltuples;
332
+ const pages = stats.relpages;
333
+ // reltuples 0/-1 means never analyzed, and relpages is only read from
334
+ // pg_class: either being absent is an unknown, never a zero.
335
+ if (rows <= 0 || pages === undefined || pages <= 0)
336
+ continue;
337
+ const { hasIdx, orderColumn } = indexShapeFor(snapshot, meta, candidate.column);
338
+ // No btree leading with the column: both plans are equally bad and the
339
+ // existing missing-index finding already owns this case.
340
+ if (!hasIdx)
341
+ continue;
342
+ // No other ordering index: no alternative plan for the generic estimate to
343
+ // run away with.
344
+ if (orderColumn === null)
345
+ continue;
346
+ // Counted HERE, before any scored gate. Everything above is a SHAPE
347
+ // question (is there an index to flip between at all?); everything below is
348
+ // a verdict on live statistics. A user must be able to tell "considered and
349
+ // clean" from "never looked", and an earlier revision incremented this after
350
+ // a silent table-size floor, so the two were indistinguishable.
351
+ candidatesConsidered++;
352
+ const dist = snapshot.columnStats?.[`${candidate.table}.${candidate.column}`];
353
+ if (!dist) {
354
+ notices.push({
355
+ table: candidate.table,
356
+ column: candidate.column,
357
+ reason: 'no pg_stats row for this column (never analyzed, or the statistics read degraded): run ANALYZE',
358
+ });
359
+ continue;
360
+ }
361
+ if (dist.nDistinct === 0) {
362
+ notices.push({
363
+ table: candidate.table,
364
+ column: candidate.column,
365
+ reason: 'n_distinct is 0 (column never analyzed): run ANALYZE, then re-check',
366
+ });
367
+ continue;
368
+ }
369
+ if (dist.mostCommonFreqs === null || dist.mostCommonFreqs.length === 0) {
370
+ notices.push({
371
+ table: candidate.table,
372
+ column: candidate.column,
373
+ reason: 'pg_stats has no most_common_freqs for this column: the value distribution cannot be scored',
374
+ });
375
+ continue;
376
+ }
377
+ const distinctValues = decodeDistinct(dist.nDistinct, rows);
378
+ if (distinctValues < 2)
379
+ continue;
380
+ const freqs = dist.mostCommonFreqs;
381
+ const nmcv = dist.mcvCount > 0 ? dist.mcvCount : freqs.length;
382
+ const freqSum = freqs.reduce((a, b) => a + b, 0);
383
+ const densestBucket = Math.max(...freqs) * rows;
384
+ // The residual (non-MCV) bucket when the MCV list does not cover every value;
385
+ // when it does (the normal case for a tenant column, whose distinct count is
386
+ // below the default statistics target), the rarest MCV IS the rarest value.
387
+ const rarestBucket = nmcv < distinctValues ? (rows * Math.max(0, 1 - freqSum)) / (distinctValues - nmcv) : Math.min(...freqs) * rows;
388
+ const genericEstimate = rows / distinctValues;
389
+ const crossover = crossoverRows(pages, t.assumedLimit);
390
+ const correlation = dist.correlation ?? 0;
391
+ // Pages the ordered index scan the generic plan keeps must walk before it
392
+ // accumulates `assumedLimit` matches of the rarest value: limit / matching
393
+ // of the table, and the whole table once the value has fewer rows than the
394
+ // limit. The bitmap plan the custom planner takes instead reads about
395
+ // min(matching, pages).
396
+ const walkPages = rarestBucket > 0 ? Math.min(pages, (t.assumedLimit * pages) / rarestBucket) : pages;
397
+ const walkFraction = walkPages / pages;
398
+ const approxAmplification = walkPages / Math.max(1, Math.min(rarestBucket, pages));
399
+ const flips = genericEstimate >= crossover && rarestBucket < crossover;
400
+ if (!flips)
401
+ continue;
402
+ if (walkPages < t.minWalkPages || walkFraction < t.minWalkFraction)
403
+ continue;
404
+ let valuesBelowCrossover = freqs.filter((f) => f * rows < crossover).length;
405
+ if (nmcv < distinctValues && rarestBucket < crossover) {
406
+ valuesBelowCrossover += Math.round(distinctValues - nmcv);
407
+ }
408
+ const columnType = meta.pgTypes[candidate.column] ?? 'text';
409
+ findings.push({
410
+ table: candidate.table,
411
+ column: candidate.column,
412
+ rows,
413
+ pages,
414
+ distinctValues,
415
+ genericEstimate,
416
+ rarestBucket,
417
+ densestBucket,
418
+ correlation,
419
+ crossoverRows: crossover,
420
+ crossoverRowsWide: crossoverRows(pages, t.wideLimit),
421
+ assumedLimit: t.assumedLimit,
422
+ valuesBelowCrossover,
423
+ walkPages,
424
+ walkFraction,
425
+ approxAmplification,
426
+ orderColumn,
427
+ columnField: meta.reverseColumnMap[candidate.column] ?? candidate.column,
428
+ orderColumnField: meta.reverseColumnMap[orderColumn] ?? orderColumn,
429
+ lastAnalyze: stats.lastAnalyze ?? null,
430
+ diagnosticSql: buildDiagnosticSql(candidate.table, candidate.column, orderColumn, columnType),
431
+ thresholds: t,
432
+ });
433
+ }
434
+ findings.sort((a, b) => b.approxAmplification - a.approxAmplification ||
435
+ a.table.localeCompare(b.table) ||
436
+ a.column.localeCompare(b.column));
437
+ return { findings, notices, candidatesConsidered };
438
+ }
package/dist/powql.d.ts CHANGED
@@ -125,6 +125,17 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
125
125
  * callers short-circuit it, because PowDB's projection fast path returned ONE
126
126
  * row for `limit 0` below 0.20.
127
127
  */
128
+ /**
129
+ * Refuse the per-query `forceCustomPlan` read option.
130
+ *
131
+ * `FindManyArgs.forceCustomPlan` documents an `UnsupportedFeatureError` on any
132
+ * engine with no PostgreSQL plan cache, and PowDB is one: it is not a
133
+ * `Dialect` at all, so it never reaches the SQL builder's own refusal at
134
+ * `preparedNameFor`. Without this the flag would be silently ignored here,
135
+ * which is precisely the "reports a guarantee that was never made" failure the
136
+ * option's refusal exists to prevent.
137
+ */
138
+ private assertNoForceCustomPlan;
128
139
  private assertPagination;
129
140
  /** A predicate that is always false, the empty-`in` / contradiction sentinel. */
130
141
  private alwaysFalse;
package/dist/powql.js CHANGED
@@ -292,6 +292,22 @@ export class PowqlInterface {
292
292
  * callers short-circuit it, because PowDB's projection fast path returned ONE
293
293
  * row for `limit 0` below 0.20.
294
294
  */
295
+ /**
296
+ * Refuse the per-query `forceCustomPlan` read option.
297
+ *
298
+ * `FindManyArgs.forceCustomPlan` documents an `UnsupportedFeatureError` on any
299
+ * engine with no PostgreSQL plan cache, and PowDB is one: it is not a
300
+ * `Dialect` at all, so it never reaches the SQL builder's own refusal at
301
+ * `preparedNameFor`. Without this the flag would be silently ignored here,
302
+ * which is precisely the "reports a guarantee that was never made" failure the
303
+ * option's refusal exists to prevent.
304
+ */
305
+ assertNoForceCustomPlan(args) {
306
+ if (args?.forceCustomPlan !== true)
307
+ return;
308
+ throw new UnsupportedFeatureError('The forceCustomPlan query option', 'powdb', 'Forcing a per-query custom plan means keeping the statement out of the PostgreSQL plan cache, and PowDB ' +
309
+ 'has no such cache to keep it out of. Remove the option, or set it only on PostgreSQL queries.');
310
+ }
295
311
  assertPagination(limit, offset, context) {
296
312
  for (const [name, value] of [
297
313
  ['limit', limit],
@@ -1121,6 +1137,7 @@ export class PowqlInterface {
1121
1137
  // Reads
1122
1138
  // -------------------------------------------------------------------------
1123
1139
  async findMany(args = {}) {
1140
+ this.assertNoForceCustomPlan(args);
1124
1141
  return this.withMiddleware('findMany', args, async () => {
1125
1142
  // `limit: 0` means "no rows" (SQL `LIMIT 0`), and answering it client-side
1126
1143
  // is correct on every engine version: PowDB's projection fast path returned
@@ -1260,6 +1277,7 @@ export class PowqlInterface {
1260
1277
  .filter((line) => line.length > 0);
1261
1278
  }
1262
1279
  async findUnique(args) {
1280
+ this.assertNoForceCustomPlan(args);
1263
1281
  // Prisma compound-unique selector → column conjunction (engine parity with
1264
1282
  // the SQL findUnique family; pure metadata, so this is a one-line adoption).
1265
1283
  if (args.where) {
@@ -1282,6 +1300,7 @@ export class PowqlInterface {
1282
1300
  });
1283
1301
  }
1284
1302
  async findFirst(args = {}) {
1303
+ this.assertNoForceCustomPlan(args);
1285
1304
  return this.withMiddleware('findFirst', args, async () => {
1286
1305
  const { rows, native, nestedPlans, linkPlans, residualWith } = await this.runFind({ ...args, limit: 1 }, 'findFirst');
1287
1306
  if (!rows.length)
@@ -2488,6 +2507,7 @@ export class PowqlInterface {
2488
2507
  // Aggregates
2489
2508
  // -------------------------------------------------------------------------
2490
2509
  async count(args = {}) {
2510
+ this.assertNoForceCustomPlan(args);
2491
2511
  return this.withMiddleware('count', (args ?? {}), async () => {
2492
2512
  const params = [];
2493
2513
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
@@ -2526,6 +2546,7 @@ export class PowqlInterface {
2526
2546
  '`_count` of a NOT NULL column are correct on every version and are never refused.');
2527
2547
  }
2528
2548
  async aggregate(args) {
2549
+ this.assertNoForceCustomPlan(args);
2529
2550
  return this.withMiddleware('aggregate', args, async () => {
2530
2551
  // One scalar query per aggregate, PowDB's bare-projection aggregate is broken.
2531
2552
  const result = {};
@@ -2571,6 +2592,7 @@ export class PowqlInterface {
2571
2592
  });
2572
2593
  }
2573
2594
  async groupBy(args) {
2595
+ this.assertNoForceCustomPlan(args);
2574
2596
  return this.withMiddleware('groupBy', args, async () => {
2575
2597
  // DISTINCT ON has no PowQL equivalent (no DISTINCT ON row source).
2576
2598
  if (args.distinctOn) {
@@ -712,6 +712,51 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
712
712
  */
713
713
  resetUnlimitedWarnings(): void;
714
714
  private emitQueryEvent;
715
+ /**
716
+ * Resolve the prepared-statement name a read should execute under, honouring
717
+ * the per-query {@link FindManyArgs.forceCustomPlan} opt-in.
718
+ *
719
+ * `forceCustomPlan: true` returns `undefined`, which sends the statement
720
+ * UNNAMED. The mechanism is NOT "PostgreSQL treats an unnamed statement as a
721
+ * one-shot plan that never enters the plan cache": the backend builds and
722
+ * saves a `CachedPlanSource` for the unnamed statement too. It works because
723
+ * node-postgres only skips Parse for a statement it has already parsed BY
724
+ * NAME (`Query.hasBeenParsed` is `this.name && connection.parsedStatements[this.name]`),
725
+ * so an unnamed statement is re-Parsed on every execution, each Parse
726
+ * replaces the unnamed cached plan source with a fresh one whose custom-plan
727
+ * counter is zero, and the five-execution threshold that precedes promotion
728
+ * is never reached. Every execution is therefore planned with the real
729
+ * parameter values.
730
+ *
731
+ * No GUC is set, no `SET LOCAL` is emitted, no transaction is opened, and no
732
+ * extra round trip is added, which is exactly why the opt-in can be per query
733
+ * while the client-level `planCacheMode` (a connection parameter) cannot be.
734
+ *
735
+ * The refusal is deliberately here, at the one seam every read execution
736
+ * passes through, rather than in each build method: the flag changes NOTHING
737
+ * about the SQL text, so a build-time check would have had to be repeated in
738
+ * every builder and could still be bypassed by a hand-executed
739
+ * `DeferredQuery`.
740
+ *
741
+ * Engines whose dialect does not report {@link Dialect.supportsPlanCacheMode}
742
+ * throw {@link UnsupportedFeatureError} (E017): the flag names a PostgreSQL
743
+ * plan-cache guarantee, and an engine with no such cache cannot make it.
744
+ * The same flag left unset (or `false`) is accepted everywhere.
745
+ *
746
+ * THE ONE COMBINATION THAT IS REFUSED RATHER THAN HONOURED. A client-level
747
+ * `planCacheMode: 'force_generic_plan'` DEFEATS this option, and that was
748
+ * MEASURED rather than reasoned about: on PostgreSQL 16.14, five executions
749
+ * of one unnamed statement read 19,107 buffers with that setting in force and
750
+ * 55 buffers with the same connection set back to `auto`, against 19,107 for
751
+ * the named statement. So the setting governs the unnamed statement too, and
752
+ * withholding the name buys nothing against it. Accepting the flag there
753
+ * would report a guarantee the very next execution breaks, so the
754
+ * contradiction throws {@link ValidationError} (E003) naming both settings.
755
+ * Turbine can only see the setting IT applied: a `plan_cache_mode` installed
756
+ * by the caller's own `SET`, by `ALTER ROLE`, or by a pooler is invisible
757
+ * here and is not refused.
758
+ */
759
+ private preparedNameFor;
715
760
  /**
716
761
  * Execute a pool.query with an optional timeout.
717
762
  * If timeout is set, races the query against a timer and rejects on expiry.