turbine-orm 0.45.0 → 0.46.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,408 @@
1
+ /**
2
+ * Index statistics - cost-aware triage for the missing-index advisor.
3
+ *
4
+ * `index-advisor.ts` decides WHICH relation probes lack an index from pure
5
+ * schema topology. This module decides whether adding each one is worth it,
6
+ * reading live Postgres statistics (table size, write volume, existing indexes,
7
+ * HOT-update ratio, column null fraction) and scoring every finding into a tier:
8
+ *
9
+ * - take-freely large table, low write rate, few existing indexes;
10
+ * - take-deliberately real write rate, many existing indexes, or HOT at risk;
11
+ * - scrutinize tiny table, append-only log shape, or stats too young.
12
+ *
13
+ * DESIGN: the file splits into two halves.
14
+ * - The COLLECTOR half (`collectStatsSnapshot`) reads pg catalogs. It uses a
15
+ * single one-connection pool, sets a statement_timeout, and treats every
16
+ * catalog read as INDIVIDUALLY OPTIONAL: a read that fails (privileges,
17
+ * CockroachDB/YugabyteDB catalog gaps, missing view) degrades that one
18
+ * signal and records a notice rather than aborting the whole snapshot.
19
+ * - The PURE half (`scoreMissingIndex`, `findInvalidIndexes`, and the exported
20
+ * threshold constants) takes a typed {@link StatsSnapshot} and imports NO pg.
21
+ * It is fully unit-testable with hand-built snapshots and is what carries the
22
+ * thresholds the CLI prints alongside every verdict.
23
+ *
24
+ * All statistics features are Postgres-only. On a non-Postgres engine the
25
+ * collector returns an unavailable snapshot and the caller falls back to the
26
+ * topology-only report.
27
+ */
28
+ import { buildDropIndexSql } from './index-advisor.js';
29
+ // ---------------------------------------------------------------------------
30
+ // Thresholds (exported + printed so a user can see exactly why a tier was chosen)
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Heuristic thresholds behind the tier decision. Exported and surfaced in the
34
+ * doctor output so the reasoning is never a black box: if a threshold is wrong
35
+ * for a workload, the user can see the exact number that drove the verdict.
36
+ */
37
+ export const STATS_THRESHOLDS = {
38
+ /** reltuples below this = tiny table; a missing index there is noise → scrutinize. */
39
+ tinyTableRows: 1_000,
40
+ /**
41
+ * Writes/day (n_tup_ins+upd+del normalized by stats age) at or above this is a
42
+ * "real" write rate: the per-index write tax is worth a second look → take deliberately.
43
+ */
44
+ highWritesPerDay: 50_000,
45
+ /**
46
+ * Existing index count at or above this: every extra index compounds the write
47
+ * and storage tax, so a new one is no longer free → take deliberately.
48
+ */
49
+ manyIndexes: 4,
50
+ /**
51
+ * HOT ratio (n_tup_hot_upd / n_tup_upd) at or above this, WITH real update
52
+ * volume, means the table currently relies on heap-only-tuple updates. A new
53
+ * index can disqualify HOT and amplify write cost → warning + take deliberately.
54
+ */
55
+ hotRatioAtRisk: 0.3,
56
+ /** Minimum n_tup_upd for the HOT signal to be statistically meaningful. */
57
+ hotMinUpdates: 1_000,
58
+ /**
59
+ * null_frac at or above this on a probed FK column: the column is mostly NULL,
60
+ * so a partial `WHERE col IS NOT NULL` index covers every relation probe at a
61
+ * fraction of the size.
62
+ */
63
+ partialNullFrac: 0.9,
64
+ /**
65
+ * Stats younger than this (days since stats_reset) cannot normalize a write
66
+ * rate; the whole report degrades to topology-only output.
67
+ */
68
+ minStatsAgeDays: 1,
69
+ /** Append-heavy log shape: n_tup_ins at or above this ... */
70
+ appendHeavyMinInserts: 100_000,
71
+ /** ... with inserts making up at or above this fraction of all writes ... */
72
+ appendHeavyInsertRatio: 0.95,
73
+ /** ... and seq_scan at or below this ("near-zero probe reads") → scrutinize. */
74
+ appendHeavyMaxSeqScan: 5,
75
+ };
76
+ /** Build an empty (fully unavailable) snapshot - the honest "no stats" baseline. */
77
+ export function emptyStatsSnapshot(notices = []) {
78
+ return {
79
+ available: false,
80
+ statsReset: null,
81
+ statsAgeDays: null,
82
+ tables: {},
83
+ indexes: [],
84
+ nullFrac: {},
85
+ notices,
86
+ };
87
+ }
88
+ /** Format a byte count as a short human string (KB/MB/GB). */
89
+ export function formatBytes(bytes) {
90
+ if (bytes == null || !Number.isFinite(bytes))
91
+ return 'size unknown';
92
+ if (bytes < 1024)
93
+ return `${bytes} B`;
94
+ const units = ['KB', 'MB', 'GB', 'TB'];
95
+ let value = bytes / 1024;
96
+ let unit = 0;
97
+ while (value >= 1024 && unit < units.length - 1) {
98
+ value /= 1024;
99
+ unit++;
100
+ }
101
+ return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
102
+ }
103
+ function formatInt(n) {
104
+ return Math.round(n).toLocaleString('en-US');
105
+ }
106
+ /**
107
+ * Score a single missing-index finding against a snapshot. Pure and total:
108
+ * every unknown degrades to a caveat rather than a fabricated number.
109
+ */
110
+ export function scoreMissingIndex(missing, snapshot) {
111
+ const t = STATS_THRESHOLDS;
112
+ const stats = snapshot.tables[missing.table];
113
+ const probingRelations = missing.probes.length;
114
+ // reltuples 0/-1 = never analyzed → unknown, NOT tiny.
115
+ const rows = stats && stats.reltuples > 0 ? stats.reltuples : null;
116
+ const sizeBytes = stats?.totalSizeBytes ?? null;
117
+ const existingIndexCount = stats?.existingIndexCount ?? null;
118
+ const statsAgeDays = snapshot.statsAgeDays;
119
+ // Highest null_frac across the probed columns (single-column probes are the
120
+ // partial-index candidates; a composite FK is never rewritten to partial).
121
+ let nullFrac = null;
122
+ for (const col of missing.columns) {
123
+ const nf = snapshot.nullFrac[`${missing.table}.${col}`];
124
+ if (nf !== undefined)
125
+ nullFrac = nullFrac === null ? nf : Math.max(nullFrac, nf);
126
+ }
127
+ const partialNotNull = missing.columns.length === 1 && nullFrac !== null && nullFrac >= t.partialNullFrac;
128
+ // Write volume, normalized by stats age.
129
+ let writesPerDay = null;
130
+ if (stats &&
131
+ stats.nTupIns !== undefined &&
132
+ stats.nTupUpd !== undefined &&
133
+ stats.nTupDel !== undefined &&
134
+ statsAgeDays !== null &&
135
+ statsAgeDays > 0) {
136
+ writesPerDay = (stats.nTupIns + stats.nTupUpd + stats.nTupDel) / statsAgeDays;
137
+ }
138
+ // HOT ratio.
139
+ const hotRatio = stats && stats.nTupUpd !== undefined && stats.nTupUpd > 0 && stats.nTupHotUpd !== undefined
140
+ ? stats.nTupHotUpd / stats.nTupUpd
141
+ : null;
142
+ const hotAtRisk = hotRatio !== null &&
143
+ stats?.nTupUpd !== undefined &&
144
+ hotRatio >= t.hotRatioAtRisk &&
145
+ stats.nTupUpd >= t.hotMinUpdates;
146
+ const hotWarning = hotAtRisk
147
+ ? `${Math.round((hotRatio ?? 0) * 100)}% of updates are HOT (${formatInt(stats?.nTupUpd ?? 0)} updates since stats reset): a new index on this table can disable heap-only-tuple updates and amplify write cost`
148
+ : null;
149
+ const metrics = {
150
+ rows,
151
+ sizeBytes,
152
+ writesPerDay,
153
+ existingIndexCount,
154
+ probingRelations,
155
+ hotRatio,
156
+ nullFrac,
157
+ statsAgeDays,
158
+ };
159
+ const reasons = [];
160
+ // --- Scrutinize conditions (they win over everything) --------------------
161
+ const noStats = !stats;
162
+ const rowsUnknown = rows === null;
163
+ const tiny = rows !== null && rows < t.tinyTableRows;
164
+ const appendHeavy = stats?.nTupIns !== undefined &&
165
+ stats.nTupIns >= t.appendHeavyMinInserts &&
166
+ writesPerDay !== null &&
167
+ stats.nTupIns / Math.max(1, (stats.nTupIns ?? 0) + (stats.nTupUpd ?? 0) + (stats.nTupDel ?? 0)) >=
168
+ t.appendHeavyInsertRatio &&
169
+ (stats.seqScan ?? Number.POSITIVE_INFINITY) <= t.appendHeavyMaxSeqScan;
170
+ const rateUntrustable = writesPerDay === null;
171
+ let tier;
172
+ if (noStats) {
173
+ tier = 'scrutinize';
174
+ reasons.push('no statistics available for this table: cannot assess cost');
175
+ }
176
+ else if (rowsUnknown) {
177
+ tier = 'scrutinize';
178
+ reasons.push('row count unknown (table never analyzed): run ANALYZE, then re-check');
179
+ }
180
+ else if (tiny) {
181
+ tier = 'scrutinize';
182
+ reasons.push(`only ${formatInt(rows ?? 0)} rows (< ${formatInt(t.tinyTableRows)}): a sequential scan here is already cheap`);
183
+ }
184
+ else if (appendHeavy) {
185
+ tier = 'scrutinize';
186
+ reasons.push(`append-heavy: ${formatInt(stats?.nTupIns ?? 0)} inserts, near-zero reads (seq_scan ${formatInt(stats?.seqScan ?? 0)}): log-shaped table rarely benefits from a read index`);
187
+ }
188
+ else if (rateUntrustable) {
189
+ // Large table but the write rate cannot be normalized (young/NULL stats).
190
+ // Cannot claim take-freely without confirming a low write rate.
191
+ tier = 'scrutinize';
192
+ reasons.push(statsAgeDays === null
193
+ ? 'stats reset time unknown: cannot normalize the write rate to confirm this is safe'
194
+ : 'write counters unavailable: cannot confirm a low write rate');
195
+ if (rows !== null)
196
+ reasons.push(`${formatInt(rows)} rows, ${formatBytes(sizeBytes)}`);
197
+ }
198
+ else {
199
+ // --- Deliberate vs free ------------------------------------------------
200
+ const realWriteRate = writesPerDay !== null && writesPerDay >= t.highWritesPerDay;
201
+ const manyIndexes = existingIndexCount !== null && existingIndexCount >= t.manyIndexes;
202
+ if (realWriteRate || manyIndexes || hotAtRisk) {
203
+ tier = 'take-deliberately';
204
+ }
205
+ else {
206
+ tier = 'take-freely';
207
+ }
208
+ reasons.push(`${formatInt(rows ?? 0)} rows, ${formatBytes(sizeBytes)}`);
209
+ if (writesPerDay !== null) {
210
+ reasons.push(`~${formatInt(writesPerDay)} writes/day since stats reset${statsAgeDays !== null ? ` (${formatStatsAge(statsAgeDays)} ago)` : ''}${realWriteRate ? ` - at/above the ${formatInt(t.highWritesPerDay)}/day threshold` : ''}`);
211
+ }
212
+ if (existingIndexCount !== null) {
213
+ reasons.push(`${existingIndexCount} existing index(es)${manyIndexes ? ` - at/above the ${t.manyIndexes}-index threshold, each new index compounds write cost` : ''}`);
214
+ }
215
+ reasons.push(`probed by ${probingRelations} relation(s)`);
216
+ }
217
+ if (hotWarning)
218
+ reasons.push(hotWarning);
219
+ if (partialNotNull) {
220
+ reasons.push(`column is ${Math.round((nullFrac ?? 0) * 100)}% NULL: suggesting a partial "WHERE ${missing.columns[0]} IS NOT NULL" index (caveat: a user-written where: { ${missing.columns[0]}: null } filter will NOT use it)`);
221
+ }
222
+ // Benefit sort key: bigger, more-probed tables first. Unknown rows sort last.
223
+ const benefitScore = (rows ?? 0) * Math.max(1, probingRelations);
224
+ return {
225
+ table: missing.table,
226
+ columns: missing.columns,
227
+ tier,
228
+ reasons,
229
+ metrics,
230
+ hotWarning,
231
+ partialNotNull,
232
+ benefitScore,
233
+ };
234
+ }
235
+ function formatStatsAge(days) {
236
+ if (days < 1) {
237
+ const hours = Math.round(days * 24);
238
+ return `${hours}h`;
239
+ }
240
+ return `${Math.round(days)}d`;
241
+ }
242
+ /**
243
+ * Indexes left INVALID (indisvalid = false) - the failure artifact of a
244
+ * `CREATE INDEX CONCURRENTLY` that errored. `IF NOT EXISTS` on a retry silently
245
+ * skips the corpse, so the fix is DROP INDEX CONCURRENTLY then rerun.
246
+ */
247
+ export function findInvalidIndexes(snapshot) {
248
+ return snapshot.indexes
249
+ .filter((idx) => !idx.isValid)
250
+ .map((idx) => ({
251
+ table: idx.table,
252
+ indexName: idx.indexName,
253
+ columns: idx.columns,
254
+ dropSql: buildDropIndexSql(idx.indexName, { concurrently: true }),
255
+ }))
256
+ .sort((a, b) => a.indexName.localeCompare(b.indexName));
257
+ }
258
+ /**
259
+ * Whether the snapshot is trustworthy enough to render tier verdicts. Empty,
260
+ * unavailable, or too-young stats degrade to the topology-only report.
261
+ */
262
+ export function isSnapshotUsable(snapshot) {
263
+ if (!snapshot.available)
264
+ return false;
265
+ if (snapshot.statsAgeDays === null || snapshot.statsAgeDays < STATS_THRESHOLDS.minStatsAgeDays)
266
+ return false;
267
+ // At least one table must have a known row count for the size sort to mean anything.
268
+ return Object.values(snapshot.tables).some((s) => s.reltuples > 0);
269
+ }
270
+ /**
271
+ * Read a live statistics snapshot from Postgres. Each catalog read is wrapped
272
+ * individually: a failure records a notice and leaves that signal absent, so a
273
+ * privilege gap or a CockroachDB/YugabyteDB catalog difference degrades one
274
+ * signal rather than the whole snapshot.
275
+ */
276
+ export async function collectStatsSnapshot(options) {
277
+ const timeout = options.statementTimeoutMs ?? 5000;
278
+ const notices = [];
279
+ const snapshot = emptyStatsSnapshot(notices);
280
+ const { Pool } = (await import('pg')).default;
281
+ const pool = new Pool({ connectionString: options.connectionString, max: 1 });
282
+ const run = async (label, text, values) => {
283
+ try {
284
+ const res = await pool.query(text, values);
285
+ return res.rows;
286
+ }
287
+ catch (err) {
288
+ notices.push(`${label} unavailable (${err instanceof Error ? err.message.split('\n')[0] : String(err)})`);
289
+ return null;
290
+ }
291
+ };
292
+ try {
293
+ // statement_timeout is best-effort; if it fails the reads still run.
294
+ await run('statement_timeout', `SET statement_timeout = ${Number(timeout)}`);
295
+ // --- stats_reset / age --------------------------------------------------
296
+ const resetRows = await run('pg_stat_database.stats_reset', `SELECT stats_reset FROM pg_stat_database WHERE datname = current_database()`);
297
+ if (resetRows && resetRows.length > 0) {
298
+ const reset = resetRows[0].stats_reset;
299
+ snapshot.statsReset = reset ? new Date(reset) : null;
300
+ if (snapshot.statsReset) {
301
+ const ageMs = Date.now() - snapshot.statsReset.getTime();
302
+ snapshot.statsAgeDays = ageMs > 0 ? ageMs / 86_400_000 : null;
303
+ }
304
+ else {
305
+ notices.push('pg_stat_database.stats_reset is NULL (statistics never reset): write rates cannot be normalized');
306
+ }
307
+ }
308
+ // --- table stats (pg_stat_user_tables) ---------------------------------
309
+ const statRows = await run('pg_stat_user_tables', `SELECT relname, n_tup_ins, n_tup_upd, n_tup_del, n_tup_hot_upd, seq_scan, seq_tup_read, n_live_tup
310
+ FROM pg_stat_user_tables
311
+ WHERE schemaname = $1 AND relname = ANY($2)`, [options.schema, options.tables]);
312
+ // --- class size + existing index count (pg_class) ----------------------
313
+ const classRows = await run('pg_class size', `SELECT c.relname,
314
+ c.reltuples::bigint::text AS reltuples,
315
+ pg_total_relation_size(c.oid)::text AS total_size,
316
+ pg_relation_size(c.oid)::text AS table_size,
317
+ (SELECT count(*) FROM pg_index i WHERE i.indrelid = c.oid)::text AS index_count
318
+ FROM pg_class c
319
+ JOIN pg_namespace n ON n.oid = c.relnamespace
320
+ WHERE n.nspname = $1 AND c.relname = ANY($2)`, [options.schema, options.tables]);
321
+ // Merge table-level signals. A table appears in the snapshot if EITHER read
322
+ // returned it, so a missing pg_stat row still yields size info and vice versa.
323
+ const tableMap = snapshot.tables;
324
+ const ensure = (name) => {
325
+ let s = tableMap[name];
326
+ if (!s) {
327
+ s = { table: name, reltuples: 0 };
328
+ tableMap[name] = s;
329
+ }
330
+ return s;
331
+ };
332
+ if (classRows) {
333
+ snapshot.available = true;
334
+ for (const row of classRows) {
335
+ const s = ensure(row.relname);
336
+ s.reltuples = Number(row.reltuples);
337
+ s.totalSizeBytes = Number(row.total_size);
338
+ s.tableSizeBytes = Number(row.table_size);
339
+ s.existingIndexCount = Number(row.index_count);
340
+ }
341
+ }
342
+ if (statRows) {
343
+ snapshot.available = true;
344
+ for (const row of statRows) {
345
+ const s = ensure(row.relname);
346
+ s.nTupIns = Number(row.n_tup_ins);
347
+ s.nTupUpd = Number(row.n_tup_upd);
348
+ s.nTupDel = Number(row.n_tup_del);
349
+ s.nTupHotUpd = Number(row.n_tup_hot_upd);
350
+ s.seqScan = Number(row.seq_scan);
351
+ s.seqTupRead = Number(row.seq_tup_read);
352
+ s.nLiveTup = Number(row.n_live_tup);
353
+ }
354
+ }
355
+ // --- invalid + all indexes (whole schema, for invalid detection) -------
356
+ const indexRows = await run('pg_index', `SELECT c.relname AS table_name,
357
+ ic.relname AS index_name,
358
+ i.indisvalid, i.indisunique, i.indisprimary, i.indisreplident,
359
+ s.idx_scan::text AS idx_scan,
360
+ (SELECT array_agg(a.attname ORDER BY k.ord)
361
+ FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
362
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum) AS columns
363
+ FROM pg_index i
364
+ JOIN pg_class c ON c.oid = i.indrelid
365
+ JOIN pg_class ic ON ic.oid = i.indexrelid
366
+ JOIN pg_namespace n ON n.oid = c.relnamespace
367
+ LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.indexrelid
368
+ WHERE n.nspname = $1`, [options.schema]);
369
+ if (indexRows) {
370
+ for (const row of indexRows) {
371
+ snapshot.indexes.push({
372
+ table: row.table_name,
373
+ indexName: row.index_name,
374
+ columns: row.columns ?? [],
375
+ idxScan: row.idx_scan == null ? undefined : Number(row.idx_scan),
376
+ isValid: row.indisvalid,
377
+ isUnique: row.indisunique,
378
+ isPrimary: row.indisprimary,
379
+ isReplicaIdent: row.indisreplident,
380
+ });
381
+ }
382
+ }
383
+ // --- null_frac for probed columns (pg_stats) ---------------------------
384
+ if (options.columns.length > 0) {
385
+ const tablesArg = options.columns.map((c) => c.table);
386
+ const colsArg = options.columns.map((c) => c.column);
387
+ const nullRows = await run('pg_stats.null_frac', `SELECT s.tablename, s.attname, s.null_frac
388
+ FROM pg_stats s
389
+ JOIN unnest($2::text[], $3::text[]) AS probe(t, c)
390
+ ON probe.t = s.tablename AND probe.c = s.attname
391
+ WHERE s.schemaname = $1`, [options.schema, tablesArg, colsArg]);
392
+ if (nullRows) {
393
+ for (const row of nullRows) {
394
+ snapshot.nullFrac[`${row.tablename}.${row.attname}`] = Number(row.null_frac);
395
+ }
396
+ }
397
+ }
398
+ }
399
+ finally {
400
+ try {
401
+ await pool.end();
402
+ }
403
+ catch {
404
+ /* pool teardown is best-effort */
405
+ }
406
+ }
407
+ return snapshot;
408
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {