turbine-orm 0.46.0 → 0.47.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.
@@ -15,7 +15,7 @@
15
15
  * turbine migrate status — Show migration status
16
16
  * turbine seed — Run seed file
17
17
  * turbine status — Show schema summary
18
- * turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently)
18
+ * turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently, --unused, --audit)
19
19
  * turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
20
20
  * turbine mcp — Start read-only MCP server over JSON-RPC stdio
21
21
  * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
@@ -171,6 +171,20 @@ function parseArgs(argv = process.argv.slice(2)) {
171
171
  case '--no-concurrently':
172
172
  result.noConcurrently = true;
173
173
  break;
174
+ case '--unused':
175
+ result.unused = true;
176
+ break;
177
+ case '--audit':
178
+ result.audit = true;
179
+ break;
180
+ case '--min-scans':
181
+ result.minScans = next ? Number.parseInt(next, 10) : undefined;
182
+ i++;
183
+ break;
184
+ case '--metrics-url':
185
+ result.metricsUrl = next;
186
+ i++;
187
+ break;
174
188
  case '--zod':
175
189
  result.zod = true;
176
190
  break;
@@ -2138,20 +2152,64 @@ async function cmdDoctor(args, config) {
2138
2152
  notices: [`statistics collection failed: ${err instanceof Error ? err.message : String(err)}`],
2139
2153
  };
2140
2154
  }
2155
+ // Table-heat boost: read _turbine_metrics (app DB or --metrics-url) and use
2156
+ // per-model heat as an extra benefit signal. Best-effort; a missing table just
2157
+ // means "heat boosting unavailable" and the triage continues without it.
2158
+ const heat = probedTables.length > 0
2159
+ ? await collectTableHeatSafe(args.metricsUrl ?? url, probedTables)
2160
+ : { available: false, tables: {}, notice: null };
2141
2161
  const invalid = (0, index_stats_js_1.findInvalidIndexes)(snapshot);
2142
2162
  const usable = (0, index_stats_js_1.isSnapshotUsable)(snapshot);
2143
- const findings = missing.map((m) => ({ missing: m, score: (0, index_stats_js_1.scoreMissingIndex)(m, snapshot) }));
2163
+ const findings = missing.map((m) => ({
2164
+ missing: m,
2165
+ score: (0, index_stats_js_1.scoreMissingIndex)(m, snapshot, heat.tables[m.table]),
2166
+ }));
2167
+ // "doctor learns to subtract": report-only drop suggestions, never a migration.
2168
+ const unusedRan = args.unused === true;
2169
+ const auditRan = args.audit === true;
2170
+ const minScans = args.minScans;
2171
+ const unused = unusedRan ? (0, index_stats_js_1.findUnusedIndexes)(snapshot, { minScans }) : [];
2172
+ const redundant = unusedRan ? (0, index_stats_js_1.findRedundantIndexes)(snapshot) : [];
2173
+ const audit = auditRan ? (0, index_stats_js_1.auditDoctorIndexes)(snapshot, (0, index_advisor_js_1.collectDoctorProbeIndexNames)(schema), { minScans }) : [];
2174
+ const subtract = { unusedRan, auditRan, minScans, unused, redundant, audit };
2144
2175
  if (jsonMode) {
2145
2176
  spinner?.stop();
2146
- console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, args }), null, 2));
2177
+ console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, args }), null, 2));
2147
2178
  return;
2148
2179
  }
2149
- await renderDoctorHuman({ spinner: spinner, schema, findings, invalid, snapshot, usable, args, config });
2180
+ await renderDoctorHuman({
2181
+ spinner: spinner,
2182
+ schema,
2183
+ findings,
2184
+ invalid,
2185
+ snapshot,
2186
+ usable,
2187
+ heat,
2188
+ subtract,
2189
+ args,
2190
+ config,
2191
+ });
2192
+ }
2193
+ /** Best-effort table-heat read: any failure degrades to unavailable, never throws. */
2194
+ async function collectTableHeatSafe(connectionString, models) {
2195
+ try {
2196
+ return await (0, index_stats_js_1.collectTableHeat)({ connectionString, models });
2197
+ }
2198
+ catch (err) {
2199
+ return {
2200
+ available: false,
2201
+ tables: {},
2202
+ notice: `heat boosting is unavailable (reading _turbine_metrics failed: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}).`,
2203
+ };
2204
+ }
2150
2205
  }
2151
- /** The stable, versioned JSON contract (schemaVersion: 1). First external consumer: BataDB import. */
2206
+ /**
2207
+ * The stable, versioned JSON contract (schemaVersion: 1). Fields are only ever
2208
+ * added, never removed or repurposed, so a parser never breaks on an upgrade.
2209
+ */
2152
2210
  function buildDoctorJson(ctx) {
2153
2211
  const concurrently = ctx.args.noConcurrently !== true;
2154
- return {
2212
+ const out = {
2155
2213
  schemaVersion: 1,
2156
2214
  scannedTables: Object.keys(ctx.schema.tables).length,
2157
2215
  stats: {
@@ -2161,6 +2219,10 @@ function buildDoctorJson(ctx) {
2161
2219
  statsAgeDays: ctx.snapshot.statsAgeDays,
2162
2220
  notices: ctx.snapshot.notices,
2163
2221
  },
2222
+ heat: {
2223
+ available: ctx.heat.available,
2224
+ notice: ctx.heat.notice,
2225
+ },
2164
2226
  thresholds: index_stats_js_1.STATS_THRESHOLDS,
2165
2227
  findings: ctx.findings.map((f) => ({
2166
2228
  table: f.missing.table,
@@ -2171,18 +2233,37 @@ function buildDoctorJson(ctx) {
2171
2233
  metrics: f.score.metrics,
2172
2234
  hotWarning: f.score.hotWarning,
2173
2235
  partialNotNull: f.score.partialNotNull,
2236
+ heatBoosted: f.score.heatBoosted,
2174
2237
  probes: f.missing.probes,
2175
2238
  createSql: doctorCreateSql(f, { concurrently }),
2176
2239
  dropSql: (0, index_advisor_js_1.buildDropIndexSql)(f.missing.indexName, { concurrently }),
2177
2240
  })),
2178
2241
  invalidIndexes: ctx.invalid,
2179
2242
  };
2243
+ // Additive: the drop-suggestion arrays appear only when --unused / --audit ran.
2244
+ if (ctx.subtract.unusedRan) {
2245
+ out.unused = ctx.subtract.unused;
2246
+ out.redundant = ctx.subtract.redundant;
2247
+ out.invalid = ctx.invalid;
2248
+ }
2249
+ if (ctx.subtract.auditRan) {
2250
+ out.audit = ctx.subtract.audit;
2251
+ }
2252
+ return out;
2180
2253
  }
2181
2254
  async function renderDoctorHuman(ctx) {
2182
- const { spinner, schema, findings, invalid, snapshot, usable, args, config } = ctx;
2255
+ const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, args, config } = ctx;
2183
2256
  spinner.succeed(`Scanned ${(0, ui_js_1.bold)(String(Object.keys(schema.tables).length))} tables`);
2184
- if (findings.length === 0 && invalid.length === 0) {
2185
- (0, ui_js_1.success)('Every relation probe is backed by an index, and no invalid indexes were found');
2257
+ const subtractRan = subtract.unusedRan || subtract.auditRan;
2258
+ const nothingToAdd = findings.length === 0 && invalid.length === 0;
2259
+ const nothingToSubtract = subtract.unused.length === 0 && subtract.redundant.length === 0 && subtract.audit.length === 0;
2260
+ if (nothingToAdd && (!subtractRan || nothingToSubtract)) {
2261
+ if (subtractRan) {
2262
+ (0, ui_js_1.success)('No never-scanned or redundant indexes found, and no invalid indexes were found');
2263
+ }
2264
+ else {
2265
+ (0, ui_js_1.success)('Every relation probe is backed by an index, and no invalid indexes were found');
2266
+ }
2186
2267
  (0, ui_js_1.newline)();
2187
2268
  return;
2188
2269
  }
@@ -2198,8 +2279,20 @@ async function renderDoctorHuman(ctx) {
2198
2279
  else {
2199
2280
  renderTopologyFallback(findings, snapshot);
2200
2281
  }
2282
+ // Heat honesty: one line when the workload-heat boost could not be sourced.
2283
+ if (!heat.available && heat.notice) {
2284
+ console.log(` ${(0, ui_js_1.dim)(`Note: ${heat.notice}`)}`);
2285
+ (0, ui_js_1.newline)();
2286
+ }
2201
2287
  }
2202
2288
  renderInvalidIndexes(invalid);
2289
+ if (subtract.unusedRan) {
2290
+ renderUnusedIndexes(subtract.unused, subtract.minScans, snapshot);
2291
+ renderRedundantIndexes(subtract.redundant);
2292
+ }
2293
+ if (subtract.auditRan) {
2294
+ renderDoctorAudit(subtract.audit, subtract.minScans, snapshot);
2295
+ }
2203
2296
  if (findings.length > 0) {
2204
2297
  if (args.fix) {
2205
2298
  renderFixMigration(findings, config, args);
@@ -2211,6 +2304,65 @@ async function renderDoctorHuman(ctx) {
2211
2304
  }
2212
2305
  }
2213
2306
  }
2307
+ /** Shared caveat block: what an idx_scan of zero does and does not prove. */
2308
+ function renderUnusedCaveats(minScans, snapshot) {
2309
+ const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
2310
+ const threshold = minScans ?? index_stats_js_1.STATS_THRESHOLDS.unusedMinScans;
2311
+ console.log(` ${(0, ui_js_1.dim)(`Usage counters are since the last stats reset (${ageLabel} ago).`)}`);
2312
+ console.log(` ${(0, ui_js_1.dim)(`Caveats: counters zero on a stats reset or crash; a read replica's index scans NEVER feed`)}`);
2313
+ console.log(` ${(0, ui_js_1.dim)(`the primary's counters, so an index only a replica uses looks dead here. Threshold: idx_scan < ${threshold}.`)}`);
2314
+ console.log(` ${(0, ui_js_1.dim)('Primary-key, unique, exclusion, and replica-identity indexes are excluded. Nothing here is auto-dropped.')}`);
2315
+ (0, ui_js_1.newline)();
2316
+ }
2317
+ /** doctor --unused: never-scanned indexes with DROP suggestions (report-only). */
2318
+ function renderUnusedIndexes(unused, minScans, snapshot) {
2319
+ if (unused.length === 0)
2320
+ return;
2321
+ const total = unused.reduce((sum, u) => sum + (u.sizeBytes ?? 0), 0);
2322
+ (0, ui_js_1.warn)(`Found ${(0, ui_js_1.bold)(String(unused.length))} never-scanned index(es) (${(0, index_stats_js_1.formatBytes)(total)} reclaimable).`);
2323
+ renderUnusedCaveats(minScans, snapshot);
2324
+ for (const u of unused) {
2325
+ console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(u.table))} ${(0, ui_js_1.dim)(`(${u.columns.join(', ') || '?'})`)} ${(0, ui_js_1.gray)(`${u.indexName} · ${u.idxScan} scans · ${(0, index_stats_js_1.formatBytes)(u.sizeBytes)}`)}`);
2326
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(u.dropSql)}`);
2327
+ (0, ui_js_1.newline)();
2328
+ }
2329
+ }
2330
+ /** doctor --unused: redundant leading-prefix indexes with DROP suggestions (report-only). */
2331
+ function renderRedundantIndexes(redundant) {
2332
+ if (redundant.length === 0)
2333
+ return;
2334
+ const total = redundant.reduce((sum, r) => sum + (r.sizeBytes ?? 0), 0);
2335
+ (0, ui_js_1.warn)(`Found ${(0, ui_js_1.bold)(String(redundant.length))} redundant index(es) (${(0, index_stats_js_1.formatBytes)(total)} reclaimable).`);
2336
+ console.log(` ${(0, ui_js_1.dim)('Each is a leading prefix of a wider index that already serves the same lookups. Report-only, never auto-dropped.')}`);
2337
+ (0, ui_js_1.newline)();
2338
+ for (const r of redundant) {
2339
+ console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(r.table))} ${(0, ui_js_1.dim)(`(${r.columns.join(', ')})`)} ${(0, ui_js_1.gray)(`${r.indexName} · ${(0, index_stats_js_1.formatBytes)(r.sizeBytes)}`)}`);
2340
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} covered by ${(0, ui_js_1.blue)(r.coveredBy)} ${(0, ui_js_1.dim)(`(${r.coveredByColumns.join(', ')})`)}`);
2341
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(r.dropSql)}`);
2342
+ (0, ui_js_1.newline)();
2343
+ }
2344
+ }
2345
+ /** doctor --audit: doctor's own previously-suggested indexes now never scanned. */
2346
+ function renderDoctorAudit(audit, minScans, snapshot) {
2347
+ if (audit.length === 0) {
2348
+ (0, ui_js_1.success)('No doctor-suggested index is going unused');
2349
+ (0, ui_js_1.newline)();
2350
+ return;
2351
+ }
2352
+ (0, ui_js_1.warn)(`doctor previously suggested these indexes; ${(0, ui_js_1.bold)(String(audit.length))} have never been scanned since the stats reset.`);
2353
+ renderUnusedCaveats(minScans, snapshot);
2354
+ for (const a of audit) {
2355
+ const tag = a.ambiguous ? (0, ui_js_1.red)(' [ambiguous: truncated name collides with another column set]') : '';
2356
+ console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(a.table))} ${(0, ui_js_1.dim)(`(${a.columns.join(', ') || '?'})`)} ${(0, ui_js_1.gray)(`${a.indexName} · ${a.idxScan} scans · ${(0, index_stats_js_1.formatBytes)(a.sizeBytes)}`)}${tag}`);
2357
+ if (a.ambiguous) {
2358
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.dim)('the 63-byte name maps to more than one probe column set; confirm before dropping')}`);
2359
+ }
2360
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(a.dropSql)}`);
2361
+ (0, ui_js_1.newline)();
2362
+ }
2363
+ console.log(` ${(0, ui_js_1.dim)('Consider dropping the ones you confirm are unused. Nothing here is auto-dropped.')}`);
2364
+ (0, ui_js_1.newline)();
2365
+ }
2214
2366
  /** Cost-aware tiered output: three sections, each finding annotated with its numbers. */
2215
2367
  function renderTiers(findings, snapshot, _args) {
2216
2368
  const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
@@ -2735,7 +2887,7 @@ function showHelp() {
2735
2887
  console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
2736
2888
  console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
2737
2889
  console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
2738
- console.log(` ${(0, ui_js_1.cyan)('doctor')} Cost-aware missing-FK-index triage ${(0, ui_js_1.dim)('(--fix, --json, --no-concurrently)')}`);
2890
+ console.log(` ${(0, ui_js_1.cyan)('doctor')} Cost-aware missing-FK-index triage ${(0, ui_js_1.dim)('(--fix, --json, --unused, --audit)')}`);
2739
2891
  console.log(` ${(0, ui_js_1.cyan)('studio')} Launch local read-only web UI ${(0, ui_js_1.dim)('(--write for writes, --demo for a sample DB)')}`);
2740
2892
  console.log(` ${(0, ui_js_1.cyan)('mcp')} Start read-only MCP server over stdio`);
2741
2893
  console.log(` ${(0, ui_js_1.cyan)('observe')} Launch metrics dashboard ${(0, ui_js_1.dim)('(requires TURBINE_OBSERVE_URL)')}`);
Binary file
@@ -65,8 +65,12 @@ exports.emptyStatsSnapshot = emptyStatsSnapshot;
65
65
  exports.formatBytes = formatBytes;
66
66
  exports.scoreMissingIndex = scoreMissingIndex;
67
67
  exports.findInvalidIndexes = findInvalidIndexes;
68
+ exports.findUnusedIndexes = findUnusedIndexes;
69
+ exports.findRedundantIndexes = findRedundantIndexes;
70
+ exports.auditDoctorIndexes = auditDoctorIndexes;
68
71
  exports.isSnapshotUsable = isSnapshotUsable;
69
72
  exports.collectStatsSnapshot = collectStatsSnapshot;
73
+ exports.collectTableHeat = collectTableHeat;
70
74
  const index_advisor_js_1 = require("./index-advisor.js");
71
75
  // ---------------------------------------------------------------------------
72
76
  // Thresholds (exported + printed so a user can see exactly why a tier was chosen)
@@ -114,6 +118,18 @@ exports.STATS_THRESHOLDS = {
114
118
  appendHeavyInsertRatio: 0.95,
115
119
  /** ... and seq_scan at or below this ("near-zero probe reads") → scrutinize. */
116
120
  appendHeavyMaxSeqScan: 5,
121
+ /**
122
+ * Default `--min-scans` for the unused-index report: an index with fewer than
123
+ * this many scans since stats_reset is flagged never-scanned. Default 1 makes
124
+ * the bare report mean exactly "idx_scan = 0".
125
+ */
126
+ unusedMinScans: 1,
127
+ /**
128
+ * Queries/min (from _turbine_metrics) at or above this makes a table "hot in
129
+ * your workload": a benefit signal that boosts the finding's priority and
130
+ * annotates it. It never downgrades a cost tier (write cost is a separate axis).
131
+ */
132
+ heatMinQueriesPerMin: 1,
117
133
  };
118
134
  /** Build an empty (fully unavailable) snapshot - the honest "no stats" baseline. */
119
135
  function emptyStatsSnapshot(notices = []) {
@@ -149,7 +165,7 @@ function formatInt(n) {
149
165
  * Score a single missing-index finding against a snapshot. Pure and total:
150
166
  * every unknown degrades to a caveat rather than a fabricated number.
151
167
  */
152
- function scoreMissingIndex(missing, snapshot) {
168
+ function scoreMissingIndex(missing, snapshot, heat) {
153
169
  const t = exports.STATS_THRESHOLDS;
154
170
  const stats = snapshot.tables[missing.table];
155
171
  const probingRelations = missing.probes.length;
@@ -261,8 +277,17 @@ function scoreMissingIndex(missing, snapshot) {
261
277
  if (partialNotNull) {
262
278
  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)`);
263
279
  }
280
+ // Workload heat is a BENEFIT signal, not a cost one: a table your app hits hard
281
+ // is a table where a missing index hurts most. It never downgrades a cost tier
282
+ // (write cost is decided above); it re-prioritizes and annotates.
283
+ const heatBoosted = heat !== undefined && heat.queriesPerMin >= t.heatMinQueriesPerMin;
284
+ if (heatBoosted && heat !== undefined) {
285
+ reasons.push(`hot in your workload: ${formatInt(heat.queriesPerMin)} queries/min, p95 ${heat.p95Ms >= 10 ? Math.round(heat.p95Ms) : heat.p95Ms.toFixed(1)} ms`);
286
+ }
264
287
  // Benefit sort key: bigger, more-probed tables first. Unknown rows sort last.
265
- const benefitScore = (rows ?? 0) * Math.max(1, probingRelations);
288
+ // A heat-boosted finding is lifted above every non-hot finding in its tier.
289
+ const base = (rows ?? 0) * Math.max(1, probingRelations);
290
+ const benefitScore = heatBoosted && heat !== undefined ? base + (heat.queriesPerMin + 1) * 1e12 : base;
266
291
  return {
267
292
  table: missing.table,
268
293
  columns: missing.columns,
@@ -271,6 +296,7 @@ function scoreMissingIndex(missing, snapshot) {
271
296
  metrics,
272
297
  hotWarning,
273
298
  partialNotNull,
299
+ heatBoosted,
274
300
  benefitScore,
275
301
  };
276
302
  }
@@ -297,6 +323,123 @@ function findInvalidIndexes(snapshot) {
297
323
  }))
298
324
  .sort((a, b) => a.indexName.localeCompare(b.indexName));
299
325
  }
326
+ // ---------------------------------------------------------------------------
327
+ // Unused-index detection (pure): doctor learns to subtract
328
+ // ---------------------------------------------------------------------------
329
+ /**
330
+ * An index constraint-backing indexes are NEVER candidates for a drop suggestion:
331
+ * a primary key, a unique constraint, or an exclusion constraint owns its index,
332
+ * and a replica-identity index is load-bearing for logical replication. Dropping
333
+ * any of these changes semantics, not just performance.
334
+ */
335
+ function isConstraintBacking(idx) {
336
+ return idx.isPrimary || idx.isUnique || idx.isExclusion === true || idx.isReplicaIdent;
337
+ }
338
+ /**
339
+ * Indexes never (or barely) scanned since the last stats reset. Report-only:
340
+ * counters reset on a crash/reset and REPLICA READS NEVER FEED PRIMARY COUNTERS,
341
+ * so an index only a read replica uses looks dead here. Constraint-backing and
342
+ * replica-identity indexes are excluded by construction. An index whose idx_scan
343
+ * could not be read (no pg_stat row) is skipped rather than guessed.
344
+ */
345
+ function findUnusedIndexes(snapshot, options = {}) {
346
+ const minScans = options.minScans ?? exports.STATS_THRESHOLDS.unusedMinScans;
347
+ return snapshot.indexes
348
+ .filter((idx) => idx.isValid && !isConstraintBacking(idx))
349
+ .filter((idx) => idx.idxScan !== undefined && idx.idxScan < minScans)
350
+ .map((idx) => ({
351
+ table: idx.table,
352
+ indexName: idx.indexName,
353
+ columns: idx.columns,
354
+ idxScan: idx.idxScan ?? 0,
355
+ sizeBytes: idx.sizeBytes ?? null,
356
+ dropSql: (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
357
+ }))
358
+ .sort((a, b) => (b.sizeBytes ?? 0) - (a.sizeBytes ?? 0) || a.indexName.localeCompare(b.indexName));
359
+ }
360
+ /** Whether `prefix` is a strict leading prefix of `columns`. */
361
+ function isLeadingPrefix(prefix, columns) {
362
+ if (prefix.length === 0 || prefix.length >= columns.length)
363
+ return false;
364
+ return prefix.every((c, i) => columns[i] === c);
365
+ }
366
+ /**
367
+ * Non-unique indexes whose column list is a leading prefix of a WIDER index on
368
+ * the same table. A btree serves any leading-prefix lookup, so the narrow index
369
+ * is redundant. Pure metadata (no idx_scan needed).
370
+ *
371
+ * Uniqueness compatibility: only a NON-unique index is ever reported. A unique
372
+ * or primary-key prefix is load-bearing (it enforces a constraint), so it is
373
+ * never called redundant even when a wider index shares its leading columns.
374
+ */
375
+ function findRedundantIndexes(snapshot) {
376
+ const byTable = new Map();
377
+ for (const idx of snapshot.indexes) {
378
+ if (!idx.isValid)
379
+ continue;
380
+ let list = byTable.get(idx.table);
381
+ if (!list) {
382
+ list = [];
383
+ byTable.set(idx.table, list);
384
+ }
385
+ list.push(idx);
386
+ }
387
+ const out = [];
388
+ for (const list of byTable.values()) {
389
+ for (const narrow of list) {
390
+ // Candidate must be a plain, non-constraint index: dropping it must not
391
+ // remove a uniqueness/PK/exclusion guarantee or a replica identity.
392
+ if (isConstraintBacking(narrow))
393
+ continue;
394
+ if (narrow.columns.length === 0)
395
+ continue;
396
+ const wider = list.find((w) => w.indexName !== narrow.indexName && isLeadingPrefix(narrow.columns, w.columns));
397
+ if (!wider)
398
+ continue;
399
+ out.push({
400
+ table: narrow.table,
401
+ indexName: narrow.indexName,
402
+ columns: narrow.columns,
403
+ coveredBy: wider.indexName,
404
+ coveredByColumns: wider.columns,
405
+ sizeBytes: narrow.sizeBytes ?? null,
406
+ dropSql: (0, index_advisor_js_1.buildDropIndexSql)(narrow.indexName, { concurrently: true }),
407
+ });
408
+ }
409
+ }
410
+ return out.sort((a, b) => a.table.localeCompare(b.table) || a.indexName.localeCompare(b.indexName));
411
+ }
412
+ /**
413
+ * The unused-index machinery scoped to doctor's OWN previously-suggested indexes:
414
+ * existing indexes whose name matches the `idx_<table>_<cols>` (63-byte truncated)
415
+ * shape doctor emits, never scanned since the stats reset. `doctorNames` maps each
416
+ * deterministic name to the distinct column sets that truncate to it (see
417
+ * `collectDoctorProbeIndexNames`); a name with more than one column set is a
418
+ * post-truncation collision, reported as ambiguous.
419
+ */
420
+ function auditDoctorIndexes(snapshot, doctorNames, options = {}) {
421
+ const minScans = options.minScans ?? exports.STATS_THRESHOLDS.unusedMinScans;
422
+ const out = [];
423
+ for (const idx of snapshot.indexes) {
424
+ if (!idx.isValid || isConstraintBacking(idx))
425
+ continue;
426
+ const candidates = doctorNames.get(idx.indexName);
427
+ if (!candidates || candidates.length === 0)
428
+ continue;
429
+ if (idx.idxScan === undefined || idx.idxScan >= minScans)
430
+ continue;
431
+ out.push({
432
+ table: idx.table,
433
+ indexName: idx.indexName,
434
+ columns: idx.columns,
435
+ idxScan: idx.idxScan,
436
+ sizeBytes: idx.sizeBytes ?? null,
437
+ dropSql: (0, index_advisor_js_1.buildDropIndexSql)(idx.indexName, { concurrently: true }),
438
+ ambiguous: candidates.length > 1,
439
+ });
440
+ }
441
+ return out.sort((a, b) => a.indexName.localeCompare(b.indexName));
442
+ }
300
443
  /**
301
444
  * Whether the snapshot is trustworthy enough to render tier verdicts. Empty,
302
445
  * unavailable, or too-young stats degrade to the topology-only report.
@@ -398,8 +541,11 @@ async function collectStatsSnapshot(options) {
398
541
  const indexRows = await run('pg_index', `SELECT c.relname AS table_name,
399
542
  ic.relname AS index_name,
400
543
  i.indisvalid, i.indisunique, i.indisprimary, i.indisreplident,
544
+ EXISTS (SELECT 1 FROM pg_constraint con
545
+ WHERE con.conindid = i.indexrelid AND con.contype = 'x') AS is_exclusion,
401
546
  s.idx_scan::text AS idx_scan,
402
- (SELECT array_agg(a.attname ORDER BY k.ord)
547
+ pg_relation_size(i.indexrelid)::text AS index_size,
548
+ (SELECT array_agg(a.attname::text ORDER BY k.ord)
403
549
  FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
404
550
  JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum) AS columns
405
551
  FROM pg_index i
@@ -419,6 +565,8 @@ async function collectStatsSnapshot(options) {
419
565
  isUnique: row.indisunique,
420
566
  isPrimary: row.indisprimary,
421
567
  isReplicaIdent: row.indisreplident,
568
+ isExclusion: row.is_exclusion,
569
+ sizeBytes: row.index_size == null ? undefined : Number(row.index_size),
422
570
  });
423
571
  }
424
572
  }
@@ -448,3 +596,71 @@ async function collectStatsSnapshot(options) {
448
596
  }
449
597
  return snapshot;
450
598
  }
599
+ /**
600
+ * Read per-model workload heat from a `_turbine_metrics` table (written by
601
+ * `db.$observe()` with the default Postgres sink). When the table is absent, the
602
+ * result is `available: false` with a notice: this is the expected state when
603
+ * observe uses a non-Postgres sink, or when no metrics have been collected yet.
604
+ * Every read is best-effort; a failure degrades to unavailable, never throws.
605
+ */
606
+ async function collectTableHeat(options) {
607
+ const windowMinutes = options.windowMinutes ?? 60;
608
+ const timeout = options.statementTimeoutMs ?? 5000;
609
+ const result = { available: false, tables: {}, notice: null };
610
+ if (options.models.length === 0) {
611
+ result.notice = 'no probed tables to correlate against workload heat.';
612
+ return result;
613
+ }
614
+ const { Pool } = (await Promise.resolve().then(() => __importStar(require('pg')))).default;
615
+ const pool = new Pool({ connectionString: options.connectionString, max: 1 });
616
+ try {
617
+ try {
618
+ await pool.query(`SET statement_timeout = ${Number(timeout)}`);
619
+ }
620
+ catch {
621
+ /* best-effort */
622
+ }
623
+ const exists = await pool
624
+ .query(`SELECT to_regclass('_turbine_metrics')::text AS reg`)
625
+ .then((r) => r.rows[0]?.reg ?? null)
626
+ .catch(() => null);
627
+ if (!exists) {
628
+ result.notice =
629
+ 'the _turbine_metrics table was not found: heat boosting is unavailable (observe may be using a non-Postgres sink, or no metrics have been collected yet).';
630
+ return result;
631
+ }
632
+ const rows = await pool
633
+ .query(`SELECT model,
634
+ sum(count)::float8::text AS total_count,
635
+ max(p95_ms)::text AS p95,
636
+ count(*)::text AS buckets
637
+ FROM _turbine_metrics
638
+ WHERE bucket >= NOW() - (INTERVAL '1 minute' * $1) AND model = ANY($2)
639
+ GROUP BY model`, [windowMinutes, options.models])
640
+ .then((r) => r.rows)
641
+ .catch(() => null);
642
+ if (rows === null) {
643
+ result.notice = 'reading _turbine_metrics failed: heat boosting is unavailable.';
644
+ return result;
645
+ }
646
+ result.available = true;
647
+ for (const row of rows) {
648
+ const total = Number(row.total_count);
649
+ if (!Number.isFinite(total))
650
+ continue;
651
+ result.tables[row.model] = {
652
+ queriesPerMin: total / Math.max(1, windowMinutes),
653
+ p95Ms: row.p95 == null ? 0 : Number(row.p95),
654
+ };
655
+ }
656
+ }
657
+ finally {
658
+ try {
659
+ await pool.end();
660
+ }
661
+ catch {
662
+ /* best-effort */
663
+ }
664
+ }
665
+ return result;
666
+ }
package/dist/cjs/index.js CHANGED
@@ -34,8 +34,8 @@
34
34
  * ```
35
35
  */
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- exports.withDbFieldNames = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.ReadOnlyError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
- exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.DestructivePushRefusal = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.applyManyToManyRelations = void 0;
37
+ exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.PgMetricsSink = exports.HttpJsonSink = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.ReadOnlyError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
+ exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.DestructivePushRefusal = exports.schemaDefToMetadata = exports.table = exports.isDocFieldIndexDef = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.applyManyToManyRelations = exports.withDbFieldNames = exports.snakeToPascal = void 0;
39
39
  var index_js_1 = require("./adapters/index.js");
40
40
  Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
41
41
  Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
@@ -85,6 +85,10 @@ var nested_write_js_1 = require("./nested-write.js");
85
85
  Object.defineProperty(exports, "executeNestedCreate", { enumerable: true, get: function () { return nested_write_js_1.executeNestedCreate; } });
86
86
  Object.defineProperty(exports, "executeNestedUpdate", { enumerable: true, get: function () { return nested_write_js_1.executeNestedUpdate; } });
87
87
  Object.defineProperty(exports, "hasRelationFields", { enumerable: true, get: function () { return nested_write_js_1.hasRelationFields; } });
88
+ // Observability
89
+ var observe_js_1 = require("./observe.js");
90
+ Object.defineProperty(exports, "HttpJsonSink", { enumerable: true, get: function () { return observe_js_1.HttpJsonSink; } });
91
+ Object.defineProperty(exports, "PgMetricsSink", { enumerable: true, get: function () { return observe_js_1.PgMetricsSink; } });
88
92
  // Pipeline
89
93
  var pipeline_js_1 = require("./pipeline.js");
90
94
  Object.defineProperty(exports, "executePipeline", { enumerable: true, get: function () { return pipeline_js_1.executePipeline; } });