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,511 @@
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's value distribution makes a NAMED prepared statement's generic plan
8
+ * unsafe".
9
+ *
10
+ * TWO BRANCHES, one skeleton. They differ in what the CUSTOM plan does, which is
11
+ * what decides both the boundary and the units of the damage:
12
+ *
13
+ * - `sparse-value`: the filter column IS served by a btree, so the custom plan
14
+ * takes a bitmap scan over the rare value's own rows and the boundary is a
15
+ * page comparison. This is the original rule and is unchanged.
16
+ * - `unindexed-filter`: NO index can serve `col = $1`, so the custom plan's
17
+ * only alternative is a seq scan + top-N sort. Added after an earlier
18
+ * revision dropped every unindexed column BEFORE counting it as considered,
19
+ * which put a whole class of real divergence outside the scored population
20
+ * and outside the notices too. See the branch's own header below.
21
+ *
22
+ * "Served by an index" here means a VALID, non-partial, non-expression index
23
+ * whose leading key column is the filter column and whose access method is btree
24
+ * or HASH. Hash belongs in the first branch, not the second: a hash index gives
25
+ * the planner the same bitmap-over-this-value's-rows path a btree does, which is
26
+ * the plan the crossover is derived against. Measured on the fixture below with
27
+ * a hash index on the filter column, the custom plan is a Bitmap Heap Scan
28
+ * reading 7 buffers, not the 247-page seq scan the second branch would have
29
+ * described. Any OTHER access method leading with the column (brin, gin, gist,
30
+ * spgist) is not scored at all and is reported as a notice: those paths are
31
+ * lossy or shaped differently, and neither branch's model describes them.
32
+ *
33
+ * THE MECHANISM. Postgres may promote a named prepared statement to a GENERIC
34
+ * plan from its sixth execution onward, and ONLY when the generic plan's
35
+ * estimated cost is not worse than the average custom cost. A generic plan
36
+ * cannot see any parameter value, so it substitutes a default for each one:
37
+ *
38
+ * - an unknown equality `col = $1` is estimated as reltuples / n_distinct;
39
+ * - an unknown `LIMIT $n` is estimated as 10% of the CHILD node's row estimate.
40
+ *
41
+ * THE SHAPE BOTH BRANCHES MODEL, stated narrowly on purpose. A read shaped
42
+ * `WHERE col = $1 ORDER BY <other indexed column> LIMIT $n`, where the promoted
43
+ * generic plan keeps the ordered index scan and filters, while for the table's
44
+ * rarest values the custom planner picks something else entirely.
45
+ *
46
+ * - `sparse-value` (the filter column is indexed): the generic estimate for
47
+ * `col = $1` sits ABOVE the plan boundary while some real values sit far
48
+ * BELOW it, so for those values the ordered scan walks a large fraction of
49
+ * the table before it accumulates one page of matches, where the custom plan
50
+ * takes a bitmap scan over the value's own rows and sorts them.
51
+ * - `unindexed-filter` (no equality path on the filter column): the custom plan
52
+ * takes a seq scan + top-N sort, which is bounded by the table's PAGES. The
53
+ * generic plan still takes the ordered index walk and, because it cannot see
54
+ * the value is rare, it walks nearly every TUPLE before it fills the LIMIT.
55
+ * Measured on the fixture below: 250 buffers against 20,074, and Postgres
56
+ * promoted it after five executions of the rare value.
57
+ *
58
+ * THE FIXTURE every `unindexed-filter` number in this file was measured on,
59
+ * printed once here so each number is checkable rather than asserted. PostgreSQL
60
+ * 16, warm cache, `synchronize_seqscans`, `max_parallel_workers_per_gather` and
61
+ * `jit` all off:
62
+ *
63
+ * CREATE TABLE t (id int PRIMARY KEY, organization_id int NOT NULL, payload text NOT NULL);
64
+ * INSERT INTO t SELECT g, <bucket(g)>, repeat('p', 60)
65
+ * FROM generate_series(1, N) g ORDER BY (g * 2654435761::bigint) % 1000003;
66
+ *
67
+ * which is 81 rows per page (20,000 rows in 247 pages) and, because of the
68
+ * hash-ordered INSERT, a primary key UNCORRELATED with physical position. The
69
+ * read is `WHERE organization_id = $1 ORDER BY id LIMIT $2` at limit 20, on the
70
+ * rarest bucket. The same DDL is what `plan-divergence-unindexed.integration.test.ts`
71
+ * builds, so the ladders below can be re-run from the repo.
72
+ *
73
+ * WHAT THIS DELIBERATELY DOES NOT MODEL, because it was tried and it did not
74
+ * work. An earlier revision carried a second rule for the opposite direction (a
75
+ * physically clustered column whose densest value is far ABOVE the generic
76
+ * estimate). Measured against live fixtures it was wrong more often than right,
77
+ * and twice it was wrong with the SIGN INVERTED: it predicted "at least 16,032x"
78
+ * and "at least 2,675x" on columns where the generic plan was in fact 10x and
79
+ * 105x BETTER than the custom one, so acting on the finding would have made
80
+ * those reads dramatically slower. The reason is structural, not a bad constant:
81
+ * whether that flip helps or hurts turns on WHERE in the heap the value's rows
82
+ * physically sit, and no pg_stats input carries that. `correlation` is a
83
+ * whole-column property and is identical whether the dominant band is at the
84
+ * head of the heap or its tail. The rule was removed rather than retuned.
85
+ *
86
+ * The same blind spot bounds what remains: this check can see how MANY rows a
87
+ * value has, not WHERE they are, so it under-reports (a rare value packed at the
88
+ * end of the heap is worse than modelled) and it cannot see the third-party
89
+ * shapes where a generic plan is the better one. A clean report is not evidence
90
+ * of immunity.
91
+ *
92
+ * A SECOND, STRUCTURAL blind spot bounds both branches: the candidate set is
93
+ * relation probes plus leading index columns. A plain filter column that is
94
+ * neither an FK nor indexed (a `status`, a `deleted_at`, a tenant column with no
95
+ * FK constraint) is invisible to this check in EITHER branch. Feeding the index
96
+ * advisor's recommendations in does not fix it, because the advisor derives from
97
+ * the same relation topology; closing it would need a workload source
98
+ * (pg_stat_statements or _turbine_metrics). The report says so where it prints
99
+ * how many columns were scored, rather than letting that count imply coverage.
100
+ *
101
+ * A THIRD blind spot, specific to `unindexed-filter` and disclosed on the
102
+ * finding rather than hidden: the branch cannot tell a heap that is in near-exact
103
+ * {@link PlanDivergenceFinding.orderColumn} order from one that is not, and the
104
+ * two read 1.2x and 80x on otherwise identical fixtures. The ordering column's
105
+ * pg_stats.correlation separates them, but only at the fifth decimal place of a
106
+ * sampled statistic, so it is reported and used to QUALIFY the finding
107
+ * ({@link PlanDivergenceFinding.heapNearlyOrdered}), never to suppress one. Its
108
+ * recall is bounded on the other side by a constant: gate 1 admits only a rarest
109
+ * bucket below the assumed LIMIT, which declines a measured 81x flip at bucket
110
+ * 60 (see the gate).
111
+ *
112
+ * EXPOSURE IS NOT AN INCIDENT. A finding says the DISTRIBUTION admits a
113
+ * damaging flip. It does NOT say the backend is choosing the bad plan today:
114
+ * `auto` promotes only when the generic plan's ESTIMATED cost is not worse than
115
+ * the average custom cost, and on a shape whose generic plan is estimated
116
+ * expensive it never promotes at all. That is why every finding ships a
117
+ * diagnostic that checks `pg_prepared_statements.generic_plans` BEFORE it
118
+ * compares the two plans: the counter is the only thing that establishes real
119
+ * exposure.
120
+ *
121
+ * TWO UNITS, and mixing them up is what made the removed rule wrong. In the
122
+ * `sparse-value` branch the PLANNER costs the boundary in PAGES (an ordered
123
+ * index scan reads about limit / matching of the table's pages before it fills
124
+ * the limit), which is why {@link crossoverRows} is derived from relpages and
125
+ * why measured flip points track it. The DAMAGE is also read in pages there,
126
+ * deliberately conservatively; when the ordering index is not correlated with
127
+ * heap order, each row examined is a separate block access and the true buffer
128
+ * count is larger, sometimes by an order of magnitude.
129
+ *
130
+ * The `unindexed-filter` branch reads damage in TUPLES on the generic side and
131
+ * PAGES on the custom side, because that is what the two plans actually are: an
132
+ * index-order heap fetch per row against one sequential pass. Carrying
133
+ * {@link PlanDivergenceFinding.walkPages} into it would under-report the generic
134
+ * side by exactly the rows-per-page factor, which is the entire finding. That is
135
+ * why the page-shaped fields are left UNSET on that branch instead of being
136
+ * filled with numbers from a model that does not apply.
137
+ *
138
+ * FRESHNESS GATE. The cost-tier half of doctor gates on `stats_reset` age,
139
+ * because it normalizes WRITE COUNTERS by it. Nothing here reads a counter: every
140
+ * input comes from pg_stats, which is refreshed by ANALYZE. This check therefore
141
+ * gates on ANALYZE freshness instead (a column with no pg_stats row, or an
142
+ * n_distinct of 0, is suppressed with a notice, and every finding carries the
143
+ * table's last_analyze). Reusing the counter gate would have silenced the check
144
+ * on every cluster whose `pg_stat_database.stats_reset` is NULL, which is the
145
+ * default state and has nothing to do with whether ANALYZE has ever run.
146
+ *
147
+ * Postgres-only (pg_stats + plan_cache_mode). Pure: no pg import, no EXPLAIN.
148
+ */
149
+ import { type StatsSnapshot } from './index-stats.js';
150
+ import type { SchemaMetadata } from './schema.js';
151
+ /**
152
+ * Gates behind a plan-divergence finding. Exported and printed with every
153
+ * finding so the reasoning is never a black box.
154
+ *
155
+ * Each one is either derived from the cost model and then checked against
156
+ * measurement, or fitted to a measured flip boundary; the comments record which.
157
+ */
158
+ export declare const PLAN_DIVERGENCE_THRESHOLDS: {
159
+ /**
160
+ * The LIMIT the advisor assumes, because it cannot see the application's.
161
+ *
162
+ * It is NOT a conservative bound in either direction, and an earlier comment
163
+ * here claimed it was. The crossover grows as sqrt(limit), so raising the
164
+ * limit moves BOTH gates: `rarestBucket < crossover` gets easier to satisfy
165
+ * and `genericEstimate >= crossover` gets harder, and the second one can turn
166
+ * a finding off. 20 is simply a common first page. Every finding also reports
167
+ * the crossover at {@link wideLimit} so a caller paginating in thousands can
168
+ * see where their own limit lands.
169
+ */
170
+ readonly assumedLimit: 20;
171
+ /** A second crossover reported alongside, for callers paginating in thousands. */
172
+ readonly wideLimit: 1000;
173
+ /**
174
+ * How many of the table's pages the WRONG plan must walk before this is worth
175
+ * a user's attention: ~400 KB of heap. Below it the flip cannot cost more than
176
+ * a millisecond or two however bad the ratio looks.
177
+ *
178
+ * This replaced a `relpages >= 1000` table-size floor, which was wrong and
179
+ * measurably so: a wrong plan on a SMALL table is not cheap, because an ordered
180
+ * index scan's cost is driven by how much of the table it walks, not by how big
181
+ * the table is. Fixture, stated in full so the number is checkable:
182
+ *
183
+ * CREATE TABLE t (id int PRIMARY KEY, organization_id int NOT NULL, payload text NOT NULL);
184
+ * -- 18,500 rows, repeat('p', 85) payload => 285 relpages, buckets
185
+ * -- 9,000 / 5,000 / 4,498 / 2, CREATE INDEX ON t (organization_id).
186
+ * -- read: WHERE organization_id = $1 ORDER BY id LIMIT $2, rarest value, limit 20.
187
+ *
188
+ * Inserted in `id` order the generic plan read 337 buffers against the custom
189
+ * plan's 6 (56x); inserted in hash order, the same shape read 18,574 against 7.
190
+ * The old floor dropped that column without even counting it as considered.
191
+ */
192
+ readonly minWalkPages: 50;
193
+ /**
194
+ * ...and it must walk at least this FRACTION of the table. The two gates are
195
+ * different questions (absolute cost, and how badly the plan is mismatched to
196
+ * the value), and a finding needs both.
197
+ *
198
+ * `sparse-value` only. In the `unindexed-filter` branch the walk is the whole
199
+ * table by construction (gate 1 there puts the rarest bucket below the LIMIT,
200
+ * so the ordered scan can never fill it), which makes this gate vacuous.
201
+ * Reusing it there would read as a gate that did work.
202
+ */
203
+ readonly minWalkFraction: 0.1;
204
+ /**
205
+ * `unindexed-filter` only: how many TUPLES the promoted plan must walk before
206
+ * this is worth a user's attention.
207
+ *
208
+ * Amplification in that branch is rows-per-page and nothing else, so it is
209
+ * size independent and only an absolute floor separates a real finding from
210
+ * noise. Measured on the fixture in the module header, warm cache, rarest
211
+ * bucket 2, limit 20 (rows / relpages / custom buffers / custom ms / generic
212
+ * buffers / generic ms):
213
+ *
214
+ * 1,500 / 19 / 22 / 0.14 / 1,506 / 0.28
215
+ * 5,000 / 62 / 65 / 0.17 / 5,020 / 0.64
216
+ * 10,000 / 124 / 127 / 0.29 / 10,038 / 2.20
217
+ * 20,000 / 247 / 250 / 0.83 / 20,074 / 5.84
218
+ * 200,000 / 2,470 / 2,473 / 6.06 / 200,587 / 63.07
219
+ *
220
+ * (An earlier revision of this table carried a page column about 8% high, from
221
+ * a wider row than any fixture in this repo. The numbers above come from the
222
+ * DDL printed in the module header and nothing else.)
223
+ *
224
+ * 10,000 tuples is where the warm-cache delta reaches the "a millisecond or
225
+ * two" line {@link minWalkPages} is calibrated to, and where the cold-cache
226
+ * exposure (10,000 candidate random page accesses) stops being trivial. Not
227
+ * gated on pages instead: at 10,000 rows any realistic row width already
228
+ * clears 50 pages, and a narrow table with 10,000 rows in 40 pages is still a
229
+ * 250x flip.
230
+ */
231
+ readonly minGenericTupleWalk: 10000;
232
+ /**
233
+ * `unindexed-filter` only, and it QUALIFIES a finding rather than gating it:
234
+ * at or above this correlation between the heap and {@link
235
+ * PlanDivergenceFinding.orderColumn}, the flip is real but reads ~1x, so the
236
+ * finding is printed with that stated instead of with a rows-per-page ratio
237
+ * it would not reach.
238
+ *
239
+ * Fitted to the measured ladder on {@link
240
+ * PlanDivergenceFinding.orderColumnCorrelation}: 0.99998 (one page of local
241
+ * disorder) reads 3.1x and 0.99993 (two pages) reads 41x, so the boundary is
242
+ * between them. That is the fifth decimal place of a SAMPLED statistic, which
243
+ * is exactly why nothing is suppressed on it.
244
+ */
245
+ readonly nearExactOrderCorrelation: 0.99995;
246
+ };
247
+ export type PlanDivergenceThresholds = typeof PLAN_DIVERGENCE_THRESHOLDS;
248
+ /**
249
+ * Which scoring rule produced a finding. They are NOT interchangeable: the
250
+ * boundary, the damage units, the remedy and the rendered text all differ, and
251
+ * the branch-shaped fields below are populated per branch.
252
+ */
253
+ export type PlanDivergenceBranch = 'sparse-value' | 'unindexed-filter';
254
+ export interface PlanDivergenceFinding {
255
+ /** Which rule scored this column. See {@link PlanDivergenceBranch}. */
256
+ branch: PlanDivergenceBranch;
257
+ table: string;
258
+ column: string;
259
+ /** pg_class.reltuples. */
260
+ rows: number;
261
+ /** pg_class.relpages, the size input the crossover is computed from. */
262
+ pages: number;
263
+ /** n_distinct, decoded the way Postgres decodes it (negative = fraction of rows). */
264
+ distinctValues: number;
265
+ /** rows / distinctValues: what a generic plan assumes `col = $1` matches. */
266
+ genericEstimate: number;
267
+ /** Estimated rowcount of the rarest value (MCV minimum, or the residual bucket). */
268
+ rarestBucket: number;
269
+ /** Estimated rowcount of the densest value (MCV maximum). Reported, not gated on. */
270
+ densestBucket: number;
271
+ /**
272
+ * pg_stats.correlation OF THE FILTER COLUMN, signed as reported. Reported,
273
+ * never gated on.
274
+ *
275
+ * It is NOT the statistic that decides how much the `unindexed-filter` flip
276
+ * costs. That one is {@link orderColumnCorrelation}, and an earlier revision
277
+ * printed THIS number next to a sentence about the ORDER column's physical
278
+ * order, which is a different column and, on the fixtures below, a different
279
+ * number by three orders of magnitude.
280
+ */
281
+ correlation: number;
282
+ /**
283
+ * pg_stats.correlation OF {@link orderColumn}: how closely the heap's physical
284
+ * order tracks the column the generic plan walks. Null when the column has no
285
+ * pg_stats row (it is read opportunistically, and a missing one is an unknown,
286
+ * never a zero).
287
+ *
288
+ * This is what decides the SIZE of an `unindexed-filter` flip, because the
289
+ * generic plan's cost is one heap fetch per index entry: when consecutive
290
+ * entries land on the same already-pinned page the fetch is free, and when
291
+ * they do not it is a buffer access. Measured on six otherwise identical
292
+ * 20,000-row / 247-page fixtures (the module-header DDL), varying ONLY the
293
+ * INSERT ordering, custom plan 250 buffers in every one:
294
+ *
295
+ * 1.00000 exact id order generic 303 buffers, 1.2x
296
+ * 0.99998 shuffled within ~1 page generic 783 buffers, 3.1x
297
+ * 0.99993 shuffled within ~2 pages generic 10,303 buffers, 41x
298
+ * 0.99974 shuffled within ~4 pages generic 15,148 buffers, 61x
299
+ * 0.99372 shuffled within ~20 pages generic 19,046 buffers, 76x
300
+ * -0.00065 hash order generic 20,074 buffers, 80x
301
+ *
302
+ * (An earlier revision of this table claimed 25x for the one-page row. It does
303
+ * not reproduce: the transition is between a one-page and a two-page window,
304
+ * because an index scan holds its heap pin, so disorder WITHIN a page costs
305
+ * nothing.)
306
+ *
307
+ * The plan flips in all six; only the magnitude differs. The boundary sits
308
+ * between two adjacent sampled values, so this is used to QUALIFY a finding
309
+ * ({@link heapNearlyOrdered}), never to suppress one: gating here would be
310
+ * fitted to the fifth decimal place of a sampled statistic and would drop
311
+ * genuine 41x to 80x findings.
312
+ */
313
+ orderColumnCorrelation?: number | null;
314
+ /**
315
+ * True when {@link orderColumnCorrelation} is at or above
316
+ * {@link PLAN_DIVERGENCE_THRESHOLDS.nearExactOrderCorrelation}, i.e. the heap
317
+ * is in near-exact {@link orderColumn} order and the measured amplification is
318
+ * ~1x rather than the rows-per-page figure in
319
+ * {@link worstCaseAmplification}.
320
+ *
321
+ * The known false positive of the `unindexed-filter` branch, disclosed as a
322
+ * field so a `--json` consumer sees it without re-deriving the rule. It is a
323
+ * hint to measure, not a verdict: it is one sampled statistic away from the
324
+ * 41x row of the ladder above, in both directions.
325
+ */
326
+ heapNearlyOrdered?: boolean;
327
+ /**
328
+ * sqrt(assumedLimit x pages): below this many true rows, bitmap+sort wins.
329
+ *
330
+ * `sparse-value` ONLY, and absent on the other branch rather than zero. The
331
+ * derivation compares an ordered index scan against a BITMAP scan; with no
332
+ * index on the filter column there is no bitmap path, and the real boundary is
333
+ * linear in the limit and independent of pages (see the branch-B header).
334
+ * Emitting a sqrt number there would be a measurement-shaped lie.
335
+ */
336
+ crossoverRows?: number;
337
+ /** The same crossover at {@link PLAN_DIVERGENCE_THRESHOLDS.wideLimit}. `sparse-value` only. */
338
+ crossoverRowsWide?: number;
339
+ assumedLimit: number;
340
+ /** How many distinct values sit on the wrong side of the crossover. `sparse-value` only. */
341
+ valuesBelowCrossover?: number;
342
+ /** Pages the generic plan's ordered scan walks for the rarest value. `sparse-value` only. */
343
+ walkPages?: number;
344
+ /** {@link walkPages} as a fraction of the table. `sparse-value` only. */
345
+ walkFraction?: number;
346
+ /**
347
+ * `unindexed-filter` ONLY: how many tuples the promoted ordered scan fetches
348
+ * before it fills the LIMIT, `min(rows, rows x limit / rarestBucket)`.
349
+ *
350
+ * On this branch it is ALWAYS exactly `rows`, and that is arithmetic rather
351
+ * than a coincidence: gate 1 puts `rarestBucket` below the limit, so
352
+ * `rows x limit / rarestBucket > rows` and the `min` always takes `rows`. The
353
+ * general formula is kept because gate 1 is the thing most likely to become
354
+ * size-aware (see its comment), and it was checked against measurement on one
355
+ * table at three bucket sizes: bucket 2 predicted 20,000 measured 19,992;
356
+ * bucket 20 predicted 20,000 measured 19,854; bucket 60 predicted 6,667
357
+ * measured 6,774. Two of those three bucket sizes are outside what gate 1
358
+ * currently admits, and the formula also assumes the rare value is spread
359
+ * uniformly in ORDER-column space: on a fixture with the rare rows contiguous
360
+ * at the tail of `id` order it predicted 66,667 and measured 200,547.
361
+ */
362
+ tuplesWalked?: number;
363
+ /**
364
+ * `unindexed-filter` ONLY: {@link tuplesWalked} / pages, i.e. the generic
365
+ * plan's buffer accesses against the seq scan's. Given the collapse above it
366
+ * is exactly the table's rows-per-page, which is a table property: it says how
367
+ * bad the flip is IF it happens, and nothing about how likely this column is
368
+ * to be the one that flips.
369
+ *
370
+ * An ESTIMATE, not a bound, in both directions. It assumes each index-order
371
+ * heap fetch is a separate buffer access, which is true when the heap is not
372
+ * ordered by {@link orderColumn} and false when it is: see
373
+ * {@link heapNearlyOrdered}, where the measured reading is ~1x instead.
374
+ */
375
+ worstCaseAmplification?: number;
376
+ /**
377
+ * walkPages / the pages the custom plan's bitmap scan reads, as a ROUGH scale
378
+ * only. It is an estimate from statistics, not a bound in either direction:
379
+ * it assumes the rare value's rows are spread uniformly (they usually are not,
380
+ * which makes the real number larger) and that the bitmap scan touches a
381
+ * separate page per row (which makes it smaller). On every fixture measured
382
+ * while calibrating this, the true amplification came out LARGER than the
383
+ * estimate, never smaller, but that is three fixtures and not a guarantee.
384
+ * The EXPLAIN pair shipped with the finding is what settles it.
385
+ *
386
+ * `sparse-value` only.
387
+ */
388
+ approxAmplification?: number;
389
+ /** The other indexed column the generic plan can order by (usually the PK). */
390
+ orderColumn: string;
391
+ /**
392
+ * `column` / `orderColumn` as the TypeScript FIELD names the Turbine API takes.
393
+ * The SQL in a finding names database columns; the code suggestion next to it
394
+ * must name fields, or a user pastes a snake_case key into a camelCase `where`.
395
+ */
396
+ columnField: string;
397
+ orderColumnField: string;
398
+ /**
399
+ * When the table's column statistics were last refreshed (the later of
400
+ * last_analyze / last_autoanalyze). Null when never analyzed or unreadable.
401
+ * Everything above describes the distribution AS OF this moment; a table that
402
+ * has since changed shape can make the finding wrong in either direction.
403
+ */
404
+ lastAnalyze: Date | null;
405
+ /** A copy-pasteable check that CONFIRMS or refutes the finding. */
406
+ diagnosticSql: string;
407
+ thresholds: PlanDivergenceThresholds;
408
+ }
409
+ /** A candidate suppressed because its statistics could not support a verdict. */
410
+ export interface PlanDivergenceNotice {
411
+ table: string;
412
+ column: string;
413
+ reason: string;
414
+ }
415
+ export interface PlanDivergenceReport {
416
+ findings: PlanDivergenceFinding[];
417
+ notices: PlanDivergenceNotice[];
418
+ /** How many (table, column) candidates were examined against live statistics. */
419
+ candidatesConsidered: number;
420
+ /**
421
+ * The same count split by whether the filter column has a plain btree. Reported
422
+ * separately because "N columns were scored" used to be true only of indexed
423
+ * ones, and the unindexed population was silently outside it.
424
+ */
425
+ consideredIndexed: number;
426
+ consideredUnindexed: number;
427
+ }
428
+ /** A (table, column) pair whose distribution statistics the collector should read. */
429
+ export interface DivergenceCandidate {
430
+ table: string;
431
+ column: string;
432
+ }
433
+ /**
434
+ * Every column whose value distribution is worth reading: a column Turbine
435
+ * probes by equality (a relation FK) or that the schema already indexes as a
436
+ * leading key, minus the columns that are unique on their own (a unique
437
+ * equality matches one row, so both plans agree).
438
+ *
439
+ * Purely schema-derived, so the collector knows what to read BEFORE any stats
440
+ * exist. Whether the column is really served by a btree is decided later,
441
+ * against the live index list.
442
+ *
443
+ * NOTE, because it is the obvious next idea and it is a no-op: feeding
444
+ * `findMissingRelationIndexes`' recommended columns in here adds nothing. Both
445
+ * derive from the same relation topology, this walker already enumerates every
446
+ * single-column relation probe whether or not it is indexed, and the advisor's
447
+ * only extra members are COMPOSITE probes, which this check deliberately does
448
+ * not model. The unindexed population was never missing from the candidate set;
449
+ * it was dropped later, by a shape gate. That gate is where it was fixed.
450
+ */
451
+ export declare function collectDivergenceCandidateColumns(schema: SchemaMetadata): DivergenceCandidate[];
452
+ /**
453
+ * The columns that could end up as a finding's {@link
454
+ * PlanDivergenceFinding.orderColumn}, so the collector reads their correlation
455
+ * too.
456
+ *
457
+ * A superset, deliberately: which one the generic plan would order by is decided
458
+ * later against the live index list, and a pg_stats read is cheap next to
459
+ * getting the wrong column's statistic. It is a SEPARATE list from the candidate
460
+ * columns because the two are filtered differently: a primary key is excluded
461
+ * from the candidates (a unique equality matches one row) and is the single most
462
+ * likely ordering column.
463
+ */
464
+ export declare function collectDivergenceOrderColumns(schema: SchemaMetadata): DivergenceCandidate[];
465
+ /**
466
+ * The plan-boundary crossover: below this many truly matching rows, running
467
+ * `ORDER BY <other column> LIMIT n` as bitmap-scan + sort is cheaper than
468
+ * walking the ordering index and filtering.
469
+ *
470
+ * Derivation: an ordered index scan expects to read about (limit / matching) x
471
+ * pages before it accumulates `limit` matches; a bitmap scan reads about
472
+ * min(matching, pages). Those are equal at matching = sqrt(limit x pages).
473
+ * Measured flip points tracked this within 13 to 26% across two orders of
474
+ * magnitude of limit, which is why the size input is relpages rather than a
475
+ * row count.
476
+ *
477
+ * PREMISE, and it is the same blind spot the whole check has: both halves
478
+ * assume the matching rows are SCATTERED through the heap. On a clustered
479
+ * column a bitmap scan reads far fewer than min(matching, pages), so there is
480
+ * no flip at any limit and this crossover does not describe the table at all.
481
+ * pg_stats.correlation hints at it but does not settle it (a column can be
482
+ * uncorrelated overall and still have one value packed in the tail, which is
483
+ * exactly the counterexample fixture). Treat a crossover as a reason to run
484
+ * the diagnostic block, never as a measurement.
485
+ */
486
+ export declare function crossoverRows(pages: number, limit: number): number;
487
+ /**
488
+ * Score every candidate column against the snapshot and return the ones whose
489
+ * distribution admits a damaging generic-plan flip.
490
+ *
491
+ * TWO rules, split on whether a plain btree serves the filter column, because
492
+ * that is what decides what the CUSTOM plan does and therefore where the
493
+ * boundary is and what units the damage is in.
494
+ *
495
+ * `sparse-value` (indexed): the generic estimate sits ABOVE the plan boundary
496
+ * (so a promoted plan keeps the ordered index scan) while the table's rarest
497
+ * values sit below it, and the pages that ordered scan must walk for such a
498
+ * value are worth a user's attention both in absolute terms and as a fraction of
499
+ * the table.
500
+ *
501
+ * `unindexed-filter` (no btree): see the block above its gates. Its boundary is
502
+ * linear in the LIMIT rather than sqrt in the pages, and it carries NO
503
+ * generic-side gate at all, for a measured reason recorded there.
504
+ *
505
+ * `correlation` is reported but gates nothing in EITHER branch. It used to route
506
+ * clustered columns to a second rule; that rule is gone (see the module header),
507
+ * and routing on it also made the advisor STRUCTURALLY unable to report a real
508
+ * sparse-direction flip on any column that happened to be clustered, which was
509
+ * measured at 1,165x on one fixture.
510
+ */
511
+ export declare function findPlanDivergence(schema: SchemaMetadata, snapshot: StatsSnapshot): PlanDivergenceReport;