turbine-orm 0.55.0 → 0.57.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 (52) hide show
  1. package/README.md +16 -1
  2. package/dist/cjs/cli/index.d.ts +3 -1
  3. package/dist/cjs/cli/index.js +341 -14
  4. package/dist/cjs/client.d.ts +16 -1
  5. package/dist/cjs/client.js +7 -39
  6. package/dist/cjs/dialect.d.ts +9 -0
  7. package/dist/cjs/index-stats.d.ts +46 -0
  8. package/dist/cjs/index-stats.js +42 -1
  9. package/dist/cjs/plan-divergence.d.ts +511 -0
  10. package/dist/cjs/plan-divergence.js +790 -0
  11. package/dist/cjs/powql.d.ts +11 -0
  12. package/dist/cjs/powql.js +22 -0
  13. package/dist/cjs/prisma-compat.d.ts +32 -1
  14. package/dist/cjs/prisma-compat.js +297 -41
  15. package/dist/cjs/query/builder.d.ts +45 -0
  16. package/dist/cjs/query/builder.js +90 -17
  17. package/dist/cjs/query/deferred.d.ts +9 -0
  18. package/dist/cjs/query/index.d.ts +2 -0
  19. package/dist/cjs/query/index.js +18 -1
  20. package/dist/cjs/query/option-surface.d.ts +100 -0
  21. package/dist/cjs/query/option-surface.js +214 -0
  22. package/dist/cjs/query/types.d.ts +140 -0
  23. package/dist/cjs/query/utils.d.ts +30 -0
  24. package/dist/cjs/query/utils.js +67 -3
  25. package/dist/cjs/query/warn-registry.d.ts +8 -0
  26. package/dist/cjs/query/warn-registry.js +8 -0
  27. package/dist/cli/index.d.ts +3 -1
  28. package/dist/cli/index.js +341 -14
  29. package/dist/client.d.ts +16 -1
  30. package/dist/client.js +8 -40
  31. package/dist/dialect.d.ts +9 -0
  32. package/dist/index-stats.d.ts +46 -0
  33. package/dist/index-stats.js +42 -1
  34. package/dist/plan-divergence.d.ts +511 -0
  35. package/dist/plan-divergence.js +783 -0
  36. package/dist/powql.d.ts +11 -0
  37. package/dist/powql.js +22 -0
  38. package/dist/prisma-compat.d.ts +32 -1
  39. package/dist/prisma-compat.js +297 -41
  40. package/dist/query/builder.d.ts +45 -0
  41. package/dist/query/builder.js +90 -17
  42. package/dist/query/deferred.d.ts +9 -0
  43. package/dist/query/index.d.ts +2 -0
  44. package/dist/query/index.js +1 -0
  45. package/dist/query/option-surface.d.ts +100 -0
  46. package/dist/query/option-surface.js +209 -0
  47. package/dist/query/types.d.ts +140 -0
  48. package/dist/query/utils.d.ts +30 -0
  49. package/dist/query/utils.js +66 -3
  50. package/dist/query/warn-registry.d.ts +8 -0
  51. package/dist/query/warn-registry.js +8 -0
  52. package/package.json +1 -1
@@ -0,0 +1,790 @@
1
+ "use strict";
2
+ /**
3
+ * Plan-divergence advisor, the third question doctor asks about a probe column.
4
+ *
5
+ * `index-advisor.ts` answers "which relation probes have no index" from pure
6
+ * topology. `index-stats.ts` answers "is adding that index worth it" from live
7
+ * statistics. This module answers the third question on the SAME columns: "this
8
+ * column's value distribution makes a NAMED prepared statement's generic plan
9
+ * unsafe".
10
+ *
11
+ * TWO BRANCHES, one skeleton. They differ in what the CUSTOM plan does, which is
12
+ * what decides both the boundary and the units of the damage:
13
+ *
14
+ * - `sparse-value`: the filter column IS served by a btree, so the custom plan
15
+ * takes a bitmap scan over the rare value's own rows and the boundary is a
16
+ * page comparison. This is the original rule and is unchanged.
17
+ * - `unindexed-filter`: NO index can serve `col = $1`, so the custom plan's
18
+ * only alternative is a seq scan + top-N sort. Added after an earlier
19
+ * revision dropped every unindexed column BEFORE counting it as considered,
20
+ * which put a whole class of real divergence outside the scored population
21
+ * and outside the notices too. See the branch's own header below.
22
+ *
23
+ * "Served by an index" here means a VALID, non-partial, non-expression index
24
+ * whose leading key column is the filter column and whose access method is btree
25
+ * or HASH. Hash belongs in the first branch, not the second: a hash index gives
26
+ * the planner the same bitmap-over-this-value's-rows path a btree does, which is
27
+ * the plan the crossover is derived against. Measured on the fixture below with
28
+ * a hash index on the filter column, the custom plan is a Bitmap Heap Scan
29
+ * reading 7 buffers, not the 247-page seq scan the second branch would have
30
+ * described. Any OTHER access method leading with the column (brin, gin, gist,
31
+ * spgist) is not scored at all and is reported as a notice: those paths are
32
+ * lossy or shaped differently, and neither branch's model describes them.
33
+ *
34
+ * THE MECHANISM. Postgres may promote a named prepared statement to a GENERIC
35
+ * plan from its sixth execution onward, and ONLY when the generic plan's
36
+ * estimated cost is not worse than the average custom cost. A generic plan
37
+ * cannot see any parameter value, so it substitutes a default for each one:
38
+ *
39
+ * - an unknown equality `col = $1` is estimated as reltuples / n_distinct;
40
+ * - an unknown `LIMIT $n` is estimated as 10% of the CHILD node's row estimate.
41
+ *
42
+ * THE SHAPE BOTH BRANCHES MODEL, stated narrowly on purpose. A read shaped
43
+ * `WHERE col = $1 ORDER BY <other indexed column> LIMIT $n`, where the promoted
44
+ * generic plan keeps the ordered index scan and filters, while for the table's
45
+ * rarest values the custom planner picks something else entirely.
46
+ *
47
+ * - `sparse-value` (the filter column is indexed): the generic estimate for
48
+ * `col = $1` sits ABOVE the plan boundary while some real values sit far
49
+ * BELOW it, so for those values the ordered scan walks a large fraction of
50
+ * the table before it accumulates one page of matches, where the custom plan
51
+ * takes a bitmap scan over the value's own rows and sorts them.
52
+ * - `unindexed-filter` (no equality path on the filter column): the custom plan
53
+ * takes a seq scan + top-N sort, which is bounded by the table's PAGES. The
54
+ * generic plan still takes the ordered index walk and, because it cannot see
55
+ * the value is rare, it walks nearly every TUPLE before it fills the LIMIT.
56
+ * Measured on the fixture below: 250 buffers against 20,074, and Postgres
57
+ * promoted it after five executions of the rare value.
58
+ *
59
+ * THE FIXTURE every `unindexed-filter` number in this file was measured on,
60
+ * printed once here so each number is checkable rather than asserted. PostgreSQL
61
+ * 16, warm cache, `synchronize_seqscans`, `max_parallel_workers_per_gather` and
62
+ * `jit` all off:
63
+ *
64
+ * CREATE TABLE t (id int PRIMARY KEY, organization_id int NOT NULL, payload text NOT NULL);
65
+ * INSERT INTO t SELECT g, <bucket(g)>, repeat('p', 60)
66
+ * FROM generate_series(1, N) g ORDER BY (g * 2654435761::bigint) % 1000003;
67
+ *
68
+ * which is 81 rows per page (20,000 rows in 247 pages) and, because of the
69
+ * hash-ordered INSERT, a primary key UNCORRELATED with physical position. The
70
+ * read is `WHERE organization_id = $1 ORDER BY id LIMIT $2` at limit 20, on the
71
+ * rarest bucket. The same DDL is what `plan-divergence-unindexed.integration.test.ts`
72
+ * builds, so the ladders below can be re-run from the repo.
73
+ *
74
+ * WHAT THIS DELIBERATELY DOES NOT MODEL, because it was tried and it did not
75
+ * work. An earlier revision carried a second rule for the opposite direction (a
76
+ * physically clustered column whose densest value is far ABOVE the generic
77
+ * estimate). Measured against live fixtures it was wrong more often than right,
78
+ * and twice it was wrong with the SIGN INVERTED: it predicted "at least 16,032x"
79
+ * and "at least 2,675x" on columns where the generic plan was in fact 10x and
80
+ * 105x BETTER than the custom one, so acting on the finding would have made
81
+ * those reads dramatically slower. The reason is structural, not a bad constant:
82
+ * whether that flip helps or hurts turns on WHERE in the heap the value's rows
83
+ * physically sit, and no pg_stats input carries that. `correlation` is a
84
+ * whole-column property and is identical whether the dominant band is at the
85
+ * head of the heap or its tail. The rule was removed rather than retuned.
86
+ *
87
+ * The same blind spot bounds what remains: this check can see how MANY rows a
88
+ * value has, not WHERE they are, so it under-reports (a rare value packed at the
89
+ * end of the heap is worse than modelled) and it cannot see the third-party
90
+ * shapes where a generic plan is the better one. A clean report is not evidence
91
+ * of immunity.
92
+ *
93
+ * A SECOND, STRUCTURAL blind spot bounds both branches: the candidate set is
94
+ * relation probes plus leading index columns. A plain filter column that is
95
+ * neither an FK nor indexed (a `status`, a `deleted_at`, a tenant column with no
96
+ * FK constraint) is invisible to this check in EITHER branch. Feeding the index
97
+ * advisor's recommendations in does not fix it, because the advisor derives from
98
+ * the same relation topology; closing it would need a workload source
99
+ * (pg_stat_statements or _turbine_metrics). The report says so where it prints
100
+ * how many columns were scored, rather than letting that count imply coverage.
101
+ *
102
+ * A THIRD blind spot, specific to `unindexed-filter` and disclosed on the
103
+ * finding rather than hidden: the branch cannot tell a heap that is in near-exact
104
+ * {@link PlanDivergenceFinding.orderColumn} order from one that is not, and the
105
+ * two read 1.2x and 80x on otherwise identical fixtures. The ordering column's
106
+ * pg_stats.correlation separates them, but only at the fifth decimal place of a
107
+ * sampled statistic, so it is reported and used to QUALIFY the finding
108
+ * ({@link PlanDivergenceFinding.heapNearlyOrdered}), never to suppress one. Its
109
+ * recall is bounded on the other side by a constant: gate 1 admits only a rarest
110
+ * bucket below the assumed LIMIT, which declines a measured 81x flip at bucket
111
+ * 60 (see the gate).
112
+ *
113
+ * EXPOSURE IS NOT AN INCIDENT. A finding says the DISTRIBUTION admits a
114
+ * damaging flip. It does NOT say the backend is choosing the bad plan today:
115
+ * `auto` promotes only when the generic plan's ESTIMATED cost is not worse than
116
+ * the average custom cost, and on a shape whose generic plan is estimated
117
+ * expensive it never promotes at all. That is why every finding ships a
118
+ * diagnostic that checks `pg_prepared_statements.generic_plans` BEFORE it
119
+ * compares the two plans: the counter is the only thing that establishes real
120
+ * exposure.
121
+ *
122
+ * TWO UNITS, and mixing them up is what made the removed rule wrong. In the
123
+ * `sparse-value` branch the PLANNER costs the boundary in PAGES (an ordered
124
+ * index scan reads about limit / matching of the table's pages before it fills
125
+ * the limit), which is why {@link crossoverRows} is derived from relpages and
126
+ * why measured flip points track it. The DAMAGE is also read in pages there,
127
+ * deliberately conservatively; when the ordering index is not correlated with
128
+ * heap order, each row examined is a separate block access and the true buffer
129
+ * count is larger, sometimes by an order of magnitude.
130
+ *
131
+ * The `unindexed-filter` branch reads damage in TUPLES on the generic side and
132
+ * PAGES on the custom side, because that is what the two plans actually are: an
133
+ * index-order heap fetch per row against one sequential pass. Carrying
134
+ * {@link PlanDivergenceFinding.walkPages} into it would under-report the generic
135
+ * side by exactly the rows-per-page factor, which is the entire finding. That is
136
+ * why the page-shaped fields are left UNSET on that branch instead of being
137
+ * filled with numbers from a model that does not apply.
138
+ *
139
+ * FRESHNESS GATE. The cost-tier half of doctor gates on `stats_reset` age,
140
+ * because it normalizes WRITE COUNTERS by it. Nothing here reads a counter: every
141
+ * input comes from pg_stats, which is refreshed by ANALYZE. This check therefore
142
+ * gates on ANALYZE freshness instead (a column with no pg_stats row, or an
143
+ * n_distinct of 0, is suppressed with a notice, and every finding carries the
144
+ * table's last_analyze). Reusing the counter gate would have silenced the check
145
+ * on every cluster whose `pg_stat_database.stats_reset` is NULL, which is the
146
+ * default state and has nothing to do with whether ANALYZE has ever run.
147
+ *
148
+ * Postgres-only (pg_stats + plan_cache_mode). Pure: no pg import, no EXPLAIN.
149
+ */
150
+ Object.defineProperty(exports, "__esModule", { value: true });
151
+ exports.PLAN_DIVERGENCE_THRESHOLDS = void 0;
152
+ exports.collectDivergenceCandidateColumns = collectDivergenceCandidateColumns;
153
+ exports.collectDivergenceOrderColumns = collectDivergenceOrderColumns;
154
+ exports.crossoverRows = crossoverRows;
155
+ exports.findPlanDivergence = findPlanDivergence;
156
+ const index_stats_js_1 = require("./index-stats.js");
157
+ const utils_js_1 = require("./query/utils.js");
158
+ // ---------------------------------------------------------------------------
159
+ // Thresholds
160
+ // ---------------------------------------------------------------------------
161
+ /**
162
+ * Gates behind a plan-divergence finding. Exported and printed with every
163
+ * finding so the reasoning is never a black box.
164
+ *
165
+ * Each one is either derived from the cost model and then checked against
166
+ * measurement, or fitted to a measured flip boundary; the comments record which.
167
+ */
168
+ exports.PLAN_DIVERGENCE_THRESHOLDS = {
169
+ /**
170
+ * The LIMIT the advisor assumes, because it cannot see the application's.
171
+ *
172
+ * It is NOT a conservative bound in either direction, and an earlier comment
173
+ * here claimed it was. The crossover grows as sqrt(limit), so raising the
174
+ * limit moves BOTH gates: `rarestBucket < crossover` gets easier to satisfy
175
+ * and `genericEstimate >= crossover` gets harder, and the second one can turn
176
+ * a finding off. 20 is simply a common first page. Every finding also reports
177
+ * the crossover at {@link wideLimit} so a caller paginating in thousands can
178
+ * see where their own limit lands.
179
+ */
180
+ assumedLimit: 20,
181
+ /** A second crossover reported alongside, for callers paginating in thousands. */
182
+ wideLimit: 1_000,
183
+ /**
184
+ * How many of the table's pages the WRONG plan must walk before this is worth
185
+ * a user's attention: ~400 KB of heap. Below it the flip cannot cost more than
186
+ * a millisecond or two however bad the ratio looks.
187
+ *
188
+ * This replaced a `relpages >= 1000` table-size floor, which was wrong and
189
+ * measurably so: a wrong plan on a SMALL table is not cheap, because an ordered
190
+ * index scan's cost is driven by how much of the table it walks, not by how big
191
+ * the table is. Fixture, stated in full so the number is checkable:
192
+ *
193
+ * CREATE TABLE t (id int PRIMARY KEY, organization_id int NOT NULL, payload text NOT NULL);
194
+ * -- 18,500 rows, repeat('p', 85) payload => 285 relpages, buckets
195
+ * -- 9,000 / 5,000 / 4,498 / 2, CREATE INDEX ON t (organization_id).
196
+ * -- read: WHERE organization_id = $1 ORDER BY id LIMIT $2, rarest value, limit 20.
197
+ *
198
+ * Inserted in `id` order the generic plan read 337 buffers against the custom
199
+ * plan's 6 (56x); inserted in hash order, the same shape read 18,574 against 7.
200
+ * The old floor dropped that column without even counting it as considered.
201
+ */
202
+ minWalkPages: 50,
203
+ /**
204
+ * ...and it must walk at least this FRACTION of the table. The two gates are
205
+ * different questions (absolute cost, and how badly the plan is mismatched to
206
+ * the value), and a finding needs both.
207
+ *
208
+ * `sparse-value` only. In the `unindexed-filter` branch the walk is the whole
209
+ * table by construction (gate 1 there puts the rarest bucket below the LIMIT,
210
+ * so the ordered scan can never fill it), which makes this gate vacuous.
211
+ * Reusing it there would read as a gate that did work.
212
+ */
213
+ minWalkFraction: 0.1,
214
+ /**
215
+ * `unindexed-filter` only: how many TUPLES the promoted plan must walk before
216
+ * this is worth a user's attention.
217
+ *
218
+ * Amplification in that branch is rows-per-page and nothing else, so it is
219
+ * size independent and only an absolute floor separates a real finding from
220
+ * noise. Measured on the fixture in the module header, warm cache, rarest
221
+ * bucket 2, limit 20 (rows / relpages / custom buffers / custom ms / generic
222
+ * buffers / generic ms):
223
+ *
224
+ * 1,500 / 19 / 22 / 0.14 / 1,506 / 0.28
225
+ * 5,000 / 62 / 65 / 0.17 / 5,020 / 0.64
226
+ * 10,000 / 124 / 127 / 0.29 / 10,038 / 2.20
227
+ * 20,000 / 247 / 250 / 0.83 / 20,074 / 5.84
228
+ * 200,000 / 2,470 / 2,473 / 6.06 / 200,587 / 63.07
229
+ *
230
+ * (An earlier revision of this table carried a page column about 8% high, from
231
+ * a wider row than any fixture in this repo. The numbers above come from the
232
+ * DDL printed in the module header and nothing else.)
233
+ *
234
+ * 10,000 tuples is where the warm-cache delta reaches the "a millisecond or
235
+ * two" line {@link minWalkPages} is calibrated to, and where the cold-cache
236
+ * exposure (10,000 candidate random page accesses) stops being trivial. Not
237
+ * gated on pages instead: at 10,000 rows any realistic row width already
238
+ * clears 50 pages, and a narrow table with 10,000 rows in 40 pages is still a
239
+ * 250x flip.
240
+ */
241
+ minGenericTupleWalk: 10_000,
242
+ /**
243
+ * `unindexed-filter` only, and it QUALIFIES a finding rather than gating it:
244
+ * at or above this correlation between the heap and {@link
245
+ * PlanDivergenceFinding.orderColumn}, the flip is real but reads ~1x, so the
246
+ * finding is printed with that stated instead of with a rows-per-page ratio
247
+ * it would not reach.
248
+ *
249
+ * Fitted to the measured ladder on {@link
250
+ * PlanDivergenceFinding.orderColumnCorrelation}: 0.99998 (one page of local
251
+ * disorder) reads 3.1x and 0.99993 (two pages) reads 41x, so the boundary is
252
+ * between them. That is the fifth decimal place of a SAMPLED statistic, which
253
+ * is exactly why nothing is suppressed on it.
254
+ */
255
+ nearExactOrderCorrelation: 0.99995,
256
+ };
257
+ // ---------------------------------------------------------------------------
258
+ // Candidate enumeration (schema-only)
259
+ // ---------------------------------------------------------------------------
260
+ /** True when `column` is unique on its own, so `col = $1` always matches one row. */
261
+ function isUniqueOnColumn(meta, column) {
262
+ if (meta.primaryKey.length === 1 && meta.primaryKey[0] === column)
263
+ return true;
264
+ return meta.uniqueColumns.some((cols) => cols.length === 1 && cols[0] === column);
265
+ }
266
+ /**
267
+ * Every column whose value distribution is worth reading: a column Turbine
268
+ * probes by equality (a relation FK) or that the schema already indexes as a
269
+ * leading key, minus the columns that are unique on their own (a unique
270
+ * equality matches one row, so both plans agree).
271
+ *
272
+ * Purely schema-derived, so the collector knows what to read BEFORE any stats
273
+ * exist. Whether the column is really served by a btree is decided later,
274
+ * against the live index list.
275
+ *
276
+ * NOTE, because it is the obvious next idea and it is a no-op: feeding
277
+ * `findMissingRelationIndexes`' recommended columns in here adds nothing. Both
278
+ * derive from the same relation topology, this walker already enumerates every
279
+ * single-column relation probe whether or not it is indexed, and the advisor's
280
+ * only extra members are COMPOSITE probes, which this check deliberately does
281
+ * not model. The unindexed population was never missing from the candidate set;
282
+ * it was dropped later, by a shape gate. That gate is where it was fixed.
283
+ */
284
+ function collectDivergenceCandidateColumns(schema) {
285
+ const seen = new Set();
286
+ const out = [];
287
+ const add = (table, column) => {
288
+ const meta = schema.tables[table];
289
+ if (!meta)
290
+ return;
291
+ if (!meta.allColumns.includes(column))
292
+ return;
293
+ if (isUniqueOnColumn(meta, column))
294
+ return;
295
+ const key = `${table}.${column}`;
296
+ if (seen.has(key))
297
+ return;
298
+ seen.add(key);
299
+ out.push({ table, column });
300
+ };
301
+ for (const meta of Object.values(schema.tables)) {
302
+ // Relation probe columns: the equality predicates Turbine itself emits.
303
+ for (const relDef of Object.values(meta.relations)) {
304
+ const target = schema.tables[relDef.to];
305
+ if (!target)
306
+ continue;
307
+ const keys = relDef.type === 'belongsTo' ? relDef.referenceKey : relDef.foreignKey;
308
+ const cols = Array.isArray(keys) ? keys : [keys];
309
+ // Only a SINGLE-column probe has a meaningful single-column distribution;
310
+ // a composite predicate multiplies selectivities and is not modelled here.
311
+ if (cols.length === 1 && cols[0] !== undefined)
312
+ add(relDef.to, cols[0]);
313
+ if (relDef.type === 'manyToMany' && relDef.through) {
314
+ const src = Array.isArray(relDef.through.sourceKey) ? relDef.through.sourceKey : [relDef.through.sourceKey];
315
+ if (src.length === 1 && src[0] !== undefined)
316
+ add(relDef.through.table, src[0]);
317
+ }
318
+ }
319
+ // Leading index columns: whatever the application already filters on.
320
+ for (const idx of meta.indexes) {
321
+ if (idx.docPath)
322
+ continue;
323
+ const lead = idx.columns[0];
324
+ if (lead !== undefined && lead !== index_stats_js_1.EXPRESSION_COLUMN)
325
+ add(meta.name, lead);
326
+ }
327
+ }
328
+ return out.sort((a, b) => a.table.localeCompare(b.table) || a.column.localeCompare(b.column));
329
+ }
330
+ /**
331
+ * The columns that could end up as a finding's {@link
332
+ * PlanDivergenceFinding.orderColumn}, so the collector reads their correlation
333
+ * too.
334
+ *
335
+ * A superset, deliberately: which one the generic plan would order by is decided
336
+ * later against the live index list, and a pg_stats read is cheap next to
337
+ * getting the wrong column's statistic. It is a SEPARATE list from the candidate
338
+ * columns because the two are filtered differently: a primary key is excluded
339
+ * from the candidates (a unique equality matches one row) and is the single most
340
+ * likely ordering column.
341
+ */
342
+ function collectDivergenceOrderColumns(schema) {
343
+ const seen = new Set();
344
+ const out = [];
345
+ const add = (table, column) => {
346
+ const meta = schema.tables[table];
347
+ if (!meta?.allColumns.includes(column))
348
+ return;
349
+ const key = `${table}.${column}`;
350
+ if (seen.has(key))
351
+ return;
352
+ seen.add(key);
353
+ out.push({ table, column });
354
+ };
355
+ const candidateTables = new Set(collectDivergenceCandidateColumns(schema).map((c) => c.table));
356
+ for (const table of candidateTables) {
357
+ const meta = schema.tables[table];
358
+ if (!meta)
359
+ continue;
360
+ if (meta.primaryKey[0] !== undefined)
361
+ add(table, meta.primaryKey[0]);
362
+ for (const idx of meta.indexes) {
363
+ if (idx.docPath)
364
+ continue;
365
+ const lead = idx.columns[0];
366
+ if (lead !== undefined && lead !== index_stats_js_1.EXPRESSION_COLUMN)
367
+ add(table, lead);
368
+ }
369
+ }
370
+ return out.sort((a, b) => a.table.localeCompare(b.table) || a.column.localeCompare(b.column));
371
+ }
372
+ // ---------------------------------------------------------------------------
373
+ // Live index shape
374
+ // ---------------------------------------------------------------------------
375
+ /** Access methods that give the planner an exact bitmap over one value's rows. */
376
+ const EQUALITY_ACCESS_METHODS = new Set(['btree', 'hash']);
377
+ /** Valid, no expression column, no partial predicate: usable for the bare predicate. */
378
+ function isPlainIndex(idx) {
379
+ if (!idx.isValid)
380
+ return false;
381
+ if (idx.hasExpressions === true)
382
+ return false;
383
+ if (idx.columns.includes(index_stats_js_1.EXPRESSION_COLUMN))
384
+ return false;
385
+ return idx.predicate == null;
386
+ }
387
+ /** A plain, valid btree. The only shape that can also provide ORDERING. */
388
+ function isPlainBtree(idx) {
389
+ if (!isPlainIndex(idx))
390
+ return false;
391
+ return idx.accessMethod === undefined || idx.accessMethod === 'btree';
392
+ }
393
+ /**
394
+ * True when the index gives the planner a path for `column = $1` whose custom
395
+ * plan is a bitmap over that value's own rows: a plain btree or a plain HASH
396
+ * index leading with the column.
397
+ *
398
+ * Hash is included on measurement, not on principle. On the module-header
399
+ * fixture with a hash index on the filter column, the custom plan is a Bitmap
400
+ * Heap Scan reading 7 buffers; calling that column "unindexed" would print a
401
+ * 247-page seq scan as the good plan, which is the thing being fixed.
402
+ */
403
+ function servesEquality(idx, column) {
404
+ if (!isPlainIndex(idx))
405
+ return false;
406
+ if (idx.columns[0] !== column)
407
+ return false;
408
+ return idx.accessMethod === undefined || EQUALITY_ACCESS_METHODS.has(idx.accessMethod);
409
+ }
410
+ /**
411
+ * Whether an index serves `column = $1` (so the custom plan has a bitmap path),
412
+ * whether the table also has a DIFFERENT ordering index the generic plan can run
413
+ * away with (in practice the primary key), and whether the only thing leading
414
+ * with the column is an access method neither branch models.
415
+ *
416
+ * All three matter. With NO path at all the custom plan's alternative is a seq
417
+ * scan, which is the `unindexed-filter` branch. With no alternative ordering
418
+ * index there is no other plan to flip to. And with only a brin/gin/gist path
419
+ * the column is scored by neither rule: those scans are lossy or shaped
420
+ * differently, so both models would misdescribe the custom plan.
421
+ */
422
+ function indexShapeFor(snapshot, meta, column) {
423
+ let hasIdx = false;
424
+ let orderColumn = null;
425
+ let unmodelledAccessMethod = null;
426
+ const pk = meta.primaryKey.length > 0 ? meta.primaryKey[0] : undefined;
427
+ for (const idx of snapshot.indexes) {
428
+ if (idx.table !== meta.name)
429
+ continue;
430
+ const lead = idx.columns[0];
431
+ if (lead === undefined)
432
+ continue;
433
+ if (lead === column) {
434
+ if (servesEquality(idx, column))
435
+ hasIdx = true;
436
+ else if (idx.isValid && idx.predicate == null && idx.hasExpressions !== true && idx.accessMethod !== undefined) {
437
+ unmodelledAccessMethod = idx.accessMethod;
438
+ }
439
+ continue;
440
+ }
441
+ if (!isPlainBtree(idx))
442
+ continue;
443
+ // Prefer the primary key as the stated ordering column: it is the one a
444
+ // paginated read almost always orders by, and it is the plan the generic
445
+ // estimate actually chose in every measured case.
446
+ if (orderColumn === null || lead === pk)
447
+ orderColumn = lead;
448
+ }
449
+ return { hasIdx, orderColumn, unmodelledAccessMethod: hasIdx ? null : unmodelledAccessMethod };
450
+ }
451
+ // ---------------------------------------------------------------------------
452
+ // Detection
453
+ // ---------------------------------------------------------------------------
454
+ /**
455
+ * The plan-boundary crossover: below this many truly matching rows, running
456
+ * `ORDER BY <other column> LIMIT n` as bitmap-scan + sort is cheaper than
457
+ * walking the ordering index and filtering.
458
+ *
459
+ * Derivation: an ordered index scan expects to read about (limit / matching) x
460
+ * pages before it accumulates `limit` matches; a bitmap scan reads about
461
+ * min(matching, pages). Those are equal at matching = sqrt(limit x pages).
462
+ * Measured flip points tracked this within 13 to 26% across two orders of
463
+ * magnitude of limit, which is why the size input is relpages rather than a
464
+ * row count.
465
+ *
466
+ * PREMISE, and it is the same blind spot the whole check has: both halves
467
+ * assume the matching rows are SCATTERED through the heap. On a clustered
468
+ * column a bitmap scan reads far fewer than min(matching, pages), so there is
469
+ * no flip at any limit and this crossover does not describe the table at all.
470
+ * pg_stats.correlation hints at it but does not settle it (a column can be
471
+ * uncorrelated overall and still have one value packed in the tail, which is
472
+ * exactly the counterexample fixture). Treat a crossover as a reason to run
473
+ * the diagnostic block, never as a measurement.
474
+ */
475
+ function crossoverRows(pages, limit) {
476
+ return Math.sqrt(Math.max(0, limit) * Math.max(0, pages));
477
+ }
478
+ /** Decode pg_stats.n_distinct: negative values are a fraction of the row count. */
479
+ function decodeDistinct(nDistinct, rows) {
480
+ return nDistinct > 0 ? nDistinct : -nDistinct * rows;
481
+ }
482
+ /**
483
+ * The check that settles a finding. Three steps, in the order that makes them
484
+ * mean something:
485
+ *
486
+ * 1. Does `auto` actually promote this shape? `pg_prepared_statements`
487
+ * reports `generic_plans` per cached statement, and until that counter
488
+ * leaves 0 the backend is planning with the real values and there is
489
+ * nothing to fix. This step comes FIRST because a finding describes
490
+ * exposure, not an incident, and on many shapes the generic plan is
491
+ * estimated expensive enough that `auto` never promotes at all.
492
+ * 2. What would the promoted plan cost? The two `plan_cache_mode` settings
493
+ * make both plans reachable on demand.
494
+ * 3. Put the session back, so the paste does not leave `plan_cache_mode`
495
+ * pinned for everything the user does next.
496
+ */
497
+ function buildDiagnosticSql(table, column, orderColumn, columnType, branch) {
498
+ const t = (0, utils_js_1.quoteIdent)(table);
499
+ const c = (0, utils_js_1.quoteIdent)(column);
500
+ const o = (0, utils_js_1.quoteIdent)(orderColumn);
501
+ // The `unindexed-filter` branch needs no different SQL: this PREPARE / six
502
+ // executions / generic_plans / force_custom / force_generic sequence is exactly
503
+ // what settled it on every fixture. Only the closing advice differs, because
504
+ // its remedy is the missing index, not a plan-cache setting.
505
+ const remedy = branch === 'unindexed-filter'
506
+ ? ['-- 3. the fix for THIS finding is the missing index above, not a plan-cache setting.']
507
+ : [];
508
+ return [
509
+ 'SET synchronize_seqscans = off;',
510
+ 'SET max_parallel_workers_per_gather = 0;',
511
+ `PREPARE turbine_divergence(${columnType}, int) AS`,
512
+ ` SELECT * FROM ${t} WHERE ${c} = $1 ORDER BY ${o} LIMIT $2;`,
513
+ '-- 1. does this shape get promoted at all? run it six times, then look:',
514
+ 'EXECUTE turbine_divergence(<your value>, 20); -- x6',
515
+ "SELECT generic_plans, custom_plans FROM pg_prepared_statements WHERE name = 'turbine_divergence';",
516
+ '-- generic_plans still 0 means the planner is refusing the generic plan: no exposure.',
517
+ '-- 2. what the promoted plan would cost:',
518
+ 'SET plan_cache_mode = force_custom_plan;',
519
+ 'EXPLAIN (ANALYZE, BUFFERS) EXECUTE turbine_divergence(<your value>, 20);',
520
+ 'SET plan_cache_mode = force_generic_plan;',
521
+ 'EXPLAIN (ANALYZE, BUFFERS) EXECUTE turbine_divergence(<your value>, 20);',
522
+ ...remedy,
523
+ '-- put the session back:',
524
+ 'RESET plan_cache_mode; RESET synchronize_seqscans; RESET max_parallel_workers_per_gather;',
525
+ 'DEALLOCATE turbine_divergence;',
526
+ ].join('\n');
527
+ }
528
+ /**
529
+ * The estimated EXTRA buffer accesses the wrong plan performs, and the ONE key
530
+ * both branches are sorted on.
531
+ *
532
+ * - `sparse-value`: walkPages - min(rarestBucket, pages)
533
+ * - `unindexed-filter`: tuplesWalked - pages
534
+ *
535
+ * Both are in buffer-access units, which `approxAmplification` (the previous
536
+ * key) is not: branch B does not produce it, and a ratio ranks a 76x flip on a
537
+ * 19-page table above a 81x flip on a 2,470-page one. This DOES reorder existing
538
+ * `sparse-value` findings relative to 0.56.0.
539
+ *
540
+ * Shared units are NOT the same as equal conservatism, and the ordering should
541
+ * be read as triage rather than as a ranking of true damage. Branch A's
542
+ * `walkPages` is deliberately conservative (its own field doc says the true
543
+ * buffer count can be an order of magnitude larger), while branch B counts
544
+ * exactly that per-row access. Since `rows >> pages` on any table clearing
545
+ * branch B's tuple floor, every branch-B finding outranks every branch-A one by
546
+ * roughly the rows-per-page factor whether or not it is worse. In the human
547
+ * report most branch-B findings are attached to a missing-index finding and
548
+ * leave this list; in `--json` the ordering is visible, so it is stated here. That is a deliberate behavior change: the old key is documented as "a
549
+ * ROUGH scale only", and ordering findings by a ratio put the cheapest ones on
550
+ * top.
551
+ */
552
+ function extraBufferAccesses(f) {
553
+ if (f.branch === 'unindexed-filter')
554
+ return (f.tuplesWalked ?? 0) - f.pages;
555
+ return (f.walkPages ?? 0) - Math.min(f.rarestBucket, f.pages);
556
+ }
557
+ /**
558
+ * Score every candidate column against the snapshot and return the ones whose
559
+ * distribution admits a damaging generic-plan flip.
560
+ *
561
+ * TWO rules, split on whether a plain btree serves the filter column, because
562
+ * that is what decides what the CUSTOM plan does and therefore where the
563
+ * boundary is and what units the damage is in.
564
+ *
565
+ * `sparse-value` (indexed): the generic estimate sits ABOVE the plan boundary
566
+ * (so a promoted plan keeps the ordered index scan) while the table's rarest
567
+ * values sit below it, and the pages that ordered scan must walk for such a
568
+ * value are worth a user's attention both in absolute terms and as a fraction of
569
+ * the table.
570
+ *
571
+ * `unindexed-filter` (no btree): see the block above its gates. Its boundary is
572
+ * linear in the LIMIT rather than sqrt in the pages, and it carries NO
573
+ * generic-side gate at all, for a measured reason recorded there.
574
+ *
575
+ * `correlation` is reported but gates nothing in EITHER branch. It used to route
576
+ * clustered columns to a second rule; that rule is gone (see the module header),
577
+ * and routing on it also made the advisor STRUCTURALLY unable to report a real
578
+ * sparse-direction flip on any column that happened to be clustered, which was
579
+ * measured at 1,165x on one fixture.
580
+ */
581
+ function findPlanDivergence(schema, snapshot) {
582
+ const t = exports.PLAN_DIVERGENCE_THRESHOLDS;
583
+ const findings = [];
584
+ const notices = [];
585
+ let candidatesConsidered = 0;
586
+ let consideredIndexed = 0;
587
+ let consideredUnindexed = 0;
588
+ for (const candidate of collectDivergenceCandidateColumns(schema)) {
589
+ const meta = schema.tables[candidate.table];
590
+ if (!meta)
591
+ continue;
592
+ const stats = snapshot.tables[candidate.table];
593
+ if (!stats)
594
+ continue;
595
+ const rows = stats.reltuples;
596
+ const pages = stats.relpages;
597
+ // reltuples 0/-1 means never analyzed, and relpages is only read from
598
+ // pg_class: either being absent is an unknown, never a zero.
599
+ if (rows <= 0 || pages === undefined || pages <= 0)
600
+ continue;
601
+ const { hasIdx, orderColumn, unmodelledAccessMethod } = indexShapeFor(snapshot, meta, candidate.column);
602
+ // A brin/gin/gist path serves the equality in a way neither branch's custom
603
+ // plan describes. Scored by neither rule, but SAID so: silently dropping it
604
+ // is how the unindexed population went missing in the first place.
605
+ if (unmodelledAccessMethod !== null) {
606
+ notices.push({
607
+ table: candidate.table,
608
+ column: candidate.column,
609
+ reason: `served only by a ${unmodelledAccessMethod} index: this check models btree and hash equality paths, so neither rule describes the plan here`,
610
+ });
611
+ continue;
612
+ }
613
+ // No other ordering index: no alternative plan for the generic estimate to
614
+ // run away with. The ONLY shape gate left, and it applies to both branches.
615
+ //
616
+ // `hasIdx` used to be a second one, dropped here BEFORE the considered
617
+ // counter. That put every unindexed column outside the scored population and
618
+ // outside the notices too, silently, and one of the mechanisms that produces
619
+ // real divergence lives exactly there: with no btree on the filter column
620
+ // the custom plan's alternative is a seq scan, which the generic plan will
621
+ // not choose. It now selects the branch instead of ending the candidate.
622
+ if (orderColumn === null)
623
+ continue;
624
+ const branch = hasIdx ? 'sparse-value' : 'unindexed-filter';
625
+ // Counted HERE, before any scored gate. Everything above is a SHAPE
626
+ // question (is there an index to flip between at all?); everything below is
627
+ // a verdict on live statistics. A user must be able to tell "considered and
628
+ // clean" from "never looked", and an earlier revision incremented this after
629
+ // a silent table-size floor, so the two were indistinguishable.
630
+ candidatesConsidered++;
631
+ if (hasIdx)
632
+ consideredIndexed++;
633
+ else
634
+ consideredUnindexed++;
635
+ const dist = snapshot.columnStats?.[`${candidate.table}.${candidate.column}`];
636
+ if (!dist) {
637
+ notices.push({
638
+ table: candidate.table,
639
+ column: candidate.column,
640
+ reason: 'no pg_stats row for this column (never analyzed, or the statistics read degraded): run ANALYZE',
641
+ });
642
+ continue;
643
+ }
644
+ if (dist.nDistinct === 0) {
645
+ notices.push({
646
+ table: candidate.table,
647
+ column: candidate.column,
648
+ reason: 'n_distinct is 0 (column never analyzed): run ANALYZE, then re-check',
649
+ });
650
+ continue;
651
+ }
652
+ if (dist.mostCommonFreqs === null || dist.mostCommonFreqs.length === 0) {
653
+ notices.push({
654
+ table: candidate.table,
655
+ column: candidate.column,
656
+ reason: 'pg_stats has no most_common_freqs for this column: the value distribution cannot be scored',
657
+ });
658
+ continue;
659
+ }
660
+ const distinctValues = decodeDistinct(dist.nDistinct, rows);
661
+ if (distinctValues < 2)
662
+ continue;
663
+ const freqs = dist.mostCommonFreqs;
664
+ const nmcv = dist.mcvCount > 0 ? dist.mcvCount : freqs.length;
665
+ const freqSum = freqs.reduce((a, b) => a + b, 0);
666
+ const densestBucket = Math.max(...freqs) * rows;
667
+ // The residual (non-MCV) bucket when the MCV list does not cover every value;
668
+ // when it does (the normal case for a tenant column, whose distinct count is
669
+ // below the default statistics target), the rarest MCV IS the rarest value.
670
+ const rarestBucket = nmcv < distinctValues ? (rows * Math.max(0, 1 - freqSum)) / (distinctValues - nmcv) : Math.min(...freqs) * rows;
671
+ const genericEstimate = rows / distinctValues;
672
+ const correlation = dist.correlation ?? 0;
673
+ // The ORDER column's correlation, which is the one that decides how much an
674
+ // `unindexed-filter` flip costs. Read opportunistically: a caller that did
675
+ // not ask the collector for it leaves the finding's field null rather than
676
+ // asserting a heap shape from a statistic it does not have.
677
+ const orderDist = snapshot.columnStats?.[`${candidate.table}.${orderColumn}`];
678
+ const orderColumnCorrelation = orderDist?.correlation ?? null;
679
+ // Everything common to both branches. The branch-shaped fields are filled in
680
+ // below and are left UNSET, never zero-filled, on the branch they do not
681
+ // describe.
682
+ const columnType = meta.pgTypes[candidate.column] ?? 'text';
683
+ const common = {
684
+ branch,
685
+ table: candidate.table,
686
+ column: candidate.column,
687
+ rows,
688
+ pages,
689
+ distinctValues,
690
+ genericEstimate,
691
+ rarestBucket,
692
+ densestBucket,
693
+ correlation,
694
+ orderColumnCorrelation,
695
+ assumedLimit: t.assumedLimit,
696
+ orderColumn,
697
+ columnField: meta.reverseColumnMap[candidate.column] ?? candidate.column,
698
+ orderColumnField: meta.reverseColumnMap[orderColumn] ?? orderColumn,
699
+ lastAnalyze: stats.lastAnalyze ?? null,
700
+ diagnosticSql: buildDiagnosticSql(candidate.table, candidate.column, orderColumn, columnType, branch),
701
+ thresholds: t,
702
+ };
703
+ if (branch === 'unindexed-filter') {
704
+ // BRANCH B. Two gates, and deliberately no third.
705
+ //
706
+ // GATE 1, rarestBucket < assumedLimit. The measured boundary at which the
707
+ // CUSTOM planner abandons the seq scan for the ordered walk is k x limit,
708
+ // with k = cost(full ordered index scan) / cost(seq scan + sort), measured
709
+ // 3.32 on a 267-page table and 3.245 on a 2,667-page one: the SAME
710
+ // boundary at 10x the size, so it is size independent, and it is linear in
711
+ // the limit. Gating at 1 x limit instead of the measured 3.3 x keeps this
712
+ // strictly inside the boundary with better than 2x margin, and it is also
713
+ // the regime of maximum damage: below the limit the ordered walk can never
714
+ // fill the LIMIT, so it walks the entire table.
715
+ //
716
+ // The recall this gives up is real and it GROWS with the table, in the
717
+ // same buffer-access units this module sorts on. Measured on the
718
+ // module-header fixture at 200,000 rows / 2,470 pages with a rarest bucket
719
+ // of 60: custom Seq Scan 2,473 buffers / 8.1 ms, generic PK Index Scan
720
+ // 200,547 buffers / 99.0 ms. That is an 81x flip and 198,074 extra buffer
721
+ // accesses, declined here because 60 >= 20, and it is an order of
722
+ // magnitude more absolute damage than the largest finding this branch DOES
723
+ // report on the 20,000-row fixture (19,824 extra accesses). The honest way
724
+ // to take it back is to derive k from pages, rows and the cost constants,
725
+ // not to widen the constant; until that lands it is a stated recall limit,
726
+ // not a safe one.
727
+ //
728
+ // GATE 2, an absolute floor on tuples walked. See minGenericTupleWalk.
729
+ //
730
+ // NO GENERIC-SIDE GATE, and that is the measured part rather than an
731
+ // omission. Turbine always parameterizes LIMIT on Postgres, and an unknown
732
+ // `LIMIT $n` is estimated as 10% of the child node's row estimate, so the
733
+ // generic plan's ordered index scan is discounted 10x WHATEVER the child
734
+ // estimate is. It therefore takes the ordered walk whenever
735
+ // cost(full ordered scan) < 10 x cost(seq scan + sort), i.e. whenever
736
+ // k < 10, and k measured 3.2 to 3.3 on every fixture, independent of
737
+ // n_distinct. Demonstrated directly: a 20,000-row / 267-page table with
738
+ // n_distinct 5,001 (generic estimate 4 rows, far below any crossover)
739
+ // still gave custom = Seq Scan 267 buffers and generic = PK Index Scan
740
+ // 19,994 buffers, a real 74x divergence that a `genericEstimate >=
741
+ // crossover` gate silently rejects. The generic plan's choice here is a
742
+ // property of the SHAPE, not of the distribution.
743
+ if (rarestBucket >= t.assumedLimit)
744
+ continue;
745
+ // Always exactly `rows` under gate 1; see the field's own note.
746
+ const tuplesWalked = rarestBucket > 0 ? Math.min(rows, (rows * t.assumedLimit) / rarestBucket) : rows;
747
+ if (tuplesWalked < t.minGenericTupleWalk)
748
+ continue;
749
+ findings.push({
750
+ ...common,
751
+ tuplesWalked,
752
+ worstCaseAmplification: tuplesWalked / pages,
753
+ heapNearlyOrdered: orderColumnCorrelation !== null && orderColumnCorrelation >= t.nearExactOrderCorrelation,
754
+ });
755
+ continue;
756
+ }
757
+ // BRANCH A, unchanged from 0.56.0.
758
+ const crossover = crossoverRows(pages, t.assumedLimit);
759
+ // Pages the ordered index scan the generic plan keeps must walk before it
760
+ // accumulates `assumedLimit` matches of the rarest value: limit / matching
761
+ // of the table, and the whole table once the value has fewer rows than the
762
+ // limit. The bitmap plan the custom planner takes instead reads about
763
+ // min(matching, pages).
764
+ const walkPages = rarestBucket > 0 ? Math.min(pages, (t.assumedLimit * pages) / rarestBucket) : pages;
765
+ const walkFraction = walkPages / pages;
766
+ const approxAmplification = walkPages / Math.max(1, Math.min(rarestBucket, pages));
767
+ const flips = genericEstimate >= crossover && rarestBucket < crossover;
768
+ if (!flips)
769
+ continue;
770
+ if (walkPages < t.minWalkPages || walkFraction < t.minWalkFraction)
771
+ continue;
772
+ let valuesBelowCrossover = freqs.filter((f) => f * rows < crossover).length;
773
+ if (nmcv < distinctValues && rarestBucket < crossover) {
774
+ valuesBelowCrossover += Math.round(distinctValues - nmcv);
775
+ }
776
+ findings.push({
777
+ ...common,
778
+ crossoverRows: crossover,
779
+ crossoverRowsWide: crossoverRows(pages, t.wideLimit),
780
+ valuesBelowCrossover,
781
+ walkPages,
782
+ walkFraction,
783
+ approxAmplification,
784
+ });
785
+ }
786
+ findings.sort((a, b) => extraBufferAccesses(b) - extraBufferAccesses(a) ||
787
+ a.table.localeCompare(b.table) ||
788
+ a.column.localeCompare(b.column));
789
+ return { findings, notices, candidatesConsidered, consideredIndexed, consideredUnindexed };
790
+ }