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.
@@ -1,17 +1,23 @@
1
1
  "use strict";
2
2
  /**
3
- * turbine-orm Observability module
3
+ * turbine-orm: Observability module
4
4
  *
5
5
  * Buffers query metrics in memory (keyed by model:action per minute bucket),
6
- * then periodically flushes aggregates (count, avg, p50, p95, p99, errors)
7
- * to a dedicated _turbine_metrics table. Uses a separate 1-connection pool
8
- * so metrics writes never contend with the application pool.
6
+ * then periodically flushes aggregates (count, avg, p50, p95, p99, errors) to a
7
+ * pluggable {@link ObserveSink}. The default sink writes to a dedicated
8
+ * `_turbine_metrics` Postgres table over its own 1-connection pool, so metrics
9
+ * writes never contend with the application pool; alternative sinks (for example
10
+ * {@link HttpJsonSink}) can forward the same aggregates elsewhere.
11
+ *
12
+ * The aggregation privacy posture is deliberate: a batch carries only the
13
+ * model/action identity, the counts, and the latency percentiles. It never
14
+ * carries SQL text or bound parameter values.
9
15
  */
10
16
  var __importDefault = (this && this.__importDefault) || function (mod) {
11
17
  return (mod && mod.__esModule) ? mod : { "default": mod };
12
18
  };
13
19
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.ObserveEngine = void 0;
20
+ exports.ObserveEngine = exports.HttpJsonSink = exports.PgMetricsSink = void 0;
15
21
  exports.floorToMinute = floorToMinute;
16
22
  exports.percentile = percentile;
17
23
  const pg_1 = __importDefault(require("pg"));
@@ -27,7 +33,7 @@ function percentile(sorted, p) {
27
33
  return sorted[Math.max(0, idx)];
28
34
  }
29
35
  // ---------------------------------------------------------------------------
30
- // Schema DDL
36
+ // Schema DDL + statements (default Postgres sink)
31
37
  // ---------------------------------------------------------------------------
32
38
  const SCHEMA_DDL = `
33
39
  CREATE TABLE IF NOT EXISTS _turbine_metrics (
@@ -58,22 +64,104 @@ ON CONFLICT (bucket, model, action) DO UPDATE SET
58
64
  error_count = _turbine_metrics.error_count + EXCLUDED.error_count
59
65
  `;
60
66
  const RETENTION_SQL = `DELETE FROM _turbine_metrics WHERE bucket < NOW() - INTERVAL '1 day' * $1`;
67
+ /**
68
+ * The default flush target: upserts each aggregate row into `_turbine_metrics`
69
+ * and prunes rows older than `retentionDays`. The SQL and per-row/retention
70
+ * ordering are byte-identical to the pre-sink `ObserveEngine.flush` writer.
71
+ */
72
+ class PgMetricsSink {
73
+ pool;
74
+ retentionDays;
75
+ constructor(options) {
76
+ this.pool = new pg_1.default.Pool({ connectionString: options.connectionString, max: 1 });
77
+ this.retentionDays = options.retentionDays ?? 30;
78
+ }
79
+ async init() {
80
+ await this.pool.query(SCHEMA_DDL);
81
+ }
82
+ async flush(batch) {
83
+ if (batch.rows.length === 0)
84
+ return;
85
+ for (const row of batch.rows) {
86
+ try {
87
+ await this.pool.query(UPSERT_SQL, [
88
+ row.bucket,
89
+ row.model,
90
+ row.action,
91
+ row.count,
92
+ row.avg,
93
+ row.p50,
94
+ row.p95,
95
+ row.p99,
96
+ row.errors,
97
+ ]);
98
+ }
99
+ catch {
100
+ // Fire-and-forget: never throw from flush
101
+ }
102
+ }
103
+ try {
104
+ await this.pool.query(RETENTION_SQL, [this.retentionDays]);
105
+ }
106
+ catch {
107
+ // Best effort
108
+ }
109
+ }
110
+ async stop() {
111
+ await this.pool.end();
112
+ }
113
+ }
114
+ exports.PgMetricsSink = PgMetricsSink;
115
+ /**
116
+ * Forwards each aggregate batch to an HTTP endpoint as a JSON POST. Fire-and-
117
+ * forget: a failed request is swallowed and never throws, and there are no
118
+ * retries beyond the engine's next scheduled flush. Aggregates only: the body
119
+ * carries no SQL text and no parameter values.
120
+ */
121
+ class HttpJsonSink {
122
+ url;
123
+ headers;
124
+ fetchFn;
125
+ constructor(options) {
126
+ this.url = options.url;
127
+ this.headers = options.headers ?? {};
128
+ this.fetchFn = options.fetchFunction ?? fetch;
129
+ }
130
+ async flush(batch) {
131
+ if (batch.rows.length === 0)
132
+ return;
133
+ try {
134
+ await this.fetchFn(this.url, {
135
+ method: 'POST',
136
+ headers: { 'content-type': 'application/json', ...this.headers },
137
+ body: JSON.stringify(batch),
138
+ });
139
+ }
140
+ catch {
141
+ // Fire-and-forget: a failing collector never affects the application.
142
+ }
143
+ }
144
+ }
145
+ exports.HttpJsonSink = HttpJsonSink;
61
146
  // ---------------------------------------------------------------------------
62
147
  // Observe engine
63
148
  // ---------------------------------------------------------------------------
64
149
  class ObserveEngine {
65
- pool;
150
+ sink;
66
151
  buffer = new Map();
67
152
  currentBucket;
68
153
  flushIntervalMs;
69
- retentionDays;
70
154
  timer;
71
155
  listener;
72
156
  stopped = false;
73
157
  constructor(config) {
74
- this.pool = new pg_1.default.Pool({ connectionString: config.connectionString, max: 1 });
158
+ if (!config.sink && !config.connectionString) {
159
+ throw new Error('ObserveEngine requires either a connectionString or a sink');
160
+ }
161
+ this.sink =
162
+ config.sink ??
163
+ new PgMetricsSink({ connectionString: config.connectionString, retentionDays: config.retentionDays ?? 30 });
75
164
  this.flushIntervalMs = config.flushIntervalMs ?? 60_000;
76
- this.retentionDays = config.retentionDays ?? 30;
77
165
  this.currentBucket = floorToMinute(new Date());
78
166
  this.listener = (event) => {
79
167
  if (this.stopped)
@@ -97,7 +185,7 @@ class ObserveEngine {
97
185
  return this.listener;
98
186
  }
99
187
  async init() {
100
- await this.pool.query(SCHEMA_DDL);
188
+ await this.sink.init?.();
101
189
  this.timer = setInterval(() => {
102
190
  this.flush().catch(() => { });
103
191
  }, this.flushIntervalMs);
@@ -112,34 +200,32 @@ class ObserveEngine {
112
200
  const bucket = this.currentBucket;
113
201
  const entries = new Map(this.buffer);
114
202
  this.buffer.clear();
203
+ const rows = [];
115
204
  for (const [key, entry] of entries) {
116
205
  const [model, action] = key.split(':');
117
206
  const sorted = entry.durations.slice().sort((a, b) => a - b);
118
207
  const count = sorted.length;
119
208
  const avg = sorted.reduce((s, v) => s + v, 0) / count;
120
- const p50 = percentile(sorted, 0.5);
121
- const p95 = percentile(sorted, 0.95);
122
- const p99 = percentile(sorted, 0.99);
123
- try {
124
- await this.pool.query(UPSERT_SQL, [bucket, model, action, count, avg, p50, p95, p99, entry.errors]);
125
- }
126
- catch {
127
- // Fire-and-forget — never throw from flush
128
- }
129
- }
130
- try {
131
- await this.pool.query(RETENTION_SQL, [this.retentionDays]);
132
- }
133
- catch {
134
- // Best effort
209
+ rows.push({
210
+ bucket,
211
+ model: model ?? '',
212
+ action: action ?? '',
213
+ count,
214
+ avg,
215
+ p50: percentile(sorted, 0.5),
216
+ p95: percentile(sorted, 0.95),
217
+ p99: percentile(sorted, 0.99),
218
+ errors: entry.errors,
219
+ });
135
220
  }
221
+ await this.sink.flush({ rows });
136
222
  }
137
223
  async stop() {
138
224
  this.stopped = true;
139
225
  if (this.timer)
140
226
  clearInterval(this.timer);
141
227
  await this.flush();
142
- await this.pool.end();
228
+ await this.sink.stop?.();
143
229
  }
144
230
  }
145
231
  exports.ObserveEngine = ObserveEngine;
@@ -14,7 +14,7 @@
14
14
  * turbine migrate status — Show migration status
15
15
  * turbine seed — Run seed file
16
16
  * turbine status — Show schema summary
17
- * turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently)
17
+ * turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently, --unused, --audit)
18
18
  * turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
19
19
  * turbine mcp — Start read-only MCP server over JSON-RPC stdio
20
20
  * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
@@ -51,6 +51,14 @@ export interface CliArgs {
51
51
  json?: boolean;
52
52
  /** `doctor --fix --no-concurrently`: emit plain CREATE INDEX instead of the CONCURRENTLY + no-transaction form. */
53
53
  noConcurrently?: boolean;
54
+ /** `doctor --unused`: report-only never-scanned / redundant / invalid indexes with DROP suggestions. */
55
+ unused?: boolean;
56
+ /** `doctor --audit`: unused report scoped to doctor's own previously-suggested index names. */
57
+ audit?: boolean;
58
+ /** `doctor --min-scans <n>`: idx_scan below this counts as never-scanned (default 1 = idx_scan 0). */
59
+ minScans?: number;
60
+ /** `doctor --metrics-url <url>`: read _turbine_metrics for the table-heat boost from a separate DB. */
61
+ metricsUrl?: string;
54
62
  /** `init --yes`/`-y`: accept every step's default non-interactively. */
55
63
  yes?: boolean;
56
64
  /** `init --skip-schema`: don't scaffold the schema file. */
package/dist/cli/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * turbine migrate status — Show migration status
15
15
  * turbine seed — Run seed file
16
16
  * turbine status — Show schema summary
17
- * turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently)
17
+ * turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently, --unused, --audit)
18
18
  * turbine studio : Launch local read-only web UI (--demo for a seeded sample DB)
19
19
  * turbine mcp — Start read-only MCP server over JSON-RPC stdio
20
20
  * turbine observe — Launch metrics dashboard (requires TURBINE_OBSERVE_URL)
@@ -29,8 +29,8 @@ import { tmpdir } from 'node:os';
29
29
  import { basename, dirname, extname, join, relative, resolve } from 'node:path';
30
30
  import { pathToFileURL } from 'node:url';
31
31
  import { generate, generatePrismaMap } from '../generate.js';
32
- import { buildCreateIndexSql, buildDropIndexSql, findMissingRelationIndexes, } from '../index-advisor.js';
33
- import { collectStatsSnapshot, findInvalidIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
32
+ import { buildCreateIndexSql, buildDropIndexSql, collectDoctorProbeIndexNames, findMissingRelationIndexes, } from '../index-advisor.js';
33
+ import { auditDoctorIndexes, collectStatsSnapshot, collectTableHeat, findInvalidIndexes, findRedundantIndexes, findUnusedIndexes, formatBytes, isSnapshotUsable, STATS_THRESHOLDS, scoreMissingIndex, } from '../index-stats.js';
34
34
  import { introspect } from '../introspect.js';
35
35
  import { DestructivePushRefusal, schemaDiff, schemaPush } from '../schema-sql.js';
36
36
  import { configTemplate, findConfigFile, loadConfigResult, looksLikeSchemaFilePath, resolveConfig, resolveSeedFile, unwrapModuleDefault, } from './config.js';
@@ -128,6 +128,20 @@ export function parseArgs(argv = process.argv.slice(2)) {
128
128
  case '--no-concurrently':
129
129
  result.noConcurrently = true;
130
130
  break;
131
+ case '--unused':
132
+ result.unused = true;
133
+ break;
134
+ case '--audit':
135
+ result.audit = true;
136
+ break;
137
+ case '--min-scans':
138
+ result.minScans = next ? Number.parseInt(next, 10) : undefined;
139
+ i++;
140
+ break;
141
+ case '--metrics-url':
142
+ result.metricsUrl = next;
143
+ i++;
144
+ break;
131
145
  case '--zod':
132
146
  result.zod = true;
133
147
  break;
@@ -2095,20 +2109,64 @@ async function cmdDoctor(args, config) {
2095
2109
  notices: [`statistics collection failed: ${err instanceof Error ? err.message : String(err)}`],
2096
2110
  };
2097
2111
  }
2112
+ // Table-heat boost: read _turbine_metrics (app DB or --metrics-url) and use
2113
+ // per-model heat as an extra benefit signal. Best-effort; a missing table just
2114
+ // means "heat boosting unavailable" and the triage continues without it.
2115
+ const heat = probedTables.length > 0
2116
+ ? await collectTableHeatSafe(args.metricsUrl ?? url, probedTables)
2117
+ : { available: false, tables: {}, notice: null };
2098
2118
  const invalid = findInvalidIndexes(snapshot);
2099
2119
  const usable = isSnapshotUsable(snapshot);
2100
- const findings = missing.map((m) => ({ missing: m, score: scoreMissingIndex(m, snapshot) }));
2120
+ const findings = missing.map((m) => ({
2121
+ missing: m,
2122
+ score: scoreMissingIndex(m, snapshot, heat.tables[m.table]),
2123
+ }));
2124
+ // "doctor learns to subtract": report-only drop suggestions, never a migration.
2125
+ const unusedRan = args.unused === true;
2126
+ const auditRan = args.audit === true;
2127
+ const minScans = args.minScans;
2128
+ const unused = unusedRan ? findUnusedIndexes(snapshot, { minScans }) : [];
2129
+ const redundant = unusedRan ? findRedundantIndexes(snapshot) : [];
2130
+ const audit = auditRan ? auditDoctorIndexes(snapshot, collectDoctorProbeIndexNames(schema), { minScans }) : [];
2131
+ const subtract = { unusedRan, auditRan, minScans, unused, redundant, audit };
2101
2132
  if (jsonMode) {
2102
2133
  spinner?.stop();
2103
- console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, args }), null, 2));
2134
+ console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, heat, subtract, args }), null, 2));
2104
2135
  return;
2105
2136
  }
2106
- await renderDoctorHuman({ spinner: spinner, schema, findings, invalid, snapshot, usable, args, config });
2137
+ await renderDoctorHuman({
2138
+ spinner: spinner,
2139
+ schema,
2140
+ findings,
2141
+ invalid,
2142
+ snapshot,
2143
+ usable,
2144
+ heat,
2145
+ subtract,
2146
+ args,
2147
+ config,
2148
+ });
2149
+ }
2150
+ /** Best-effort table-heat read: any failure degrades to unavailable, never throws. */
2151
+ async function collectTableHeatSafe(connectionString, models) {
2152
+ try {
2153
+ return await collectTableHeat({ connectionString, models });
2154
+ }
2155
+ catch (err) {
2156
+ return {
2157
+ available: false,
2158
+ tables: {},
2159
+ notice: `heat boosting is unavailable (reading _turbine_metrics failed: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}).`,
2160
+ };
2161
+ }
2107
2162
  }
2108
- /** The stable, versioned JSON contract (schemaVersion: 1). First external consumer: BataDB import. */
2163
+ /**
2164
+ * The stable, versioned JSON contract (schemaVersion: 1). Fields are only ever
2165
+ * added, never removed or repurposed, so a parser never breaks on an upgrade.
2166
+ */
2109
2167
  function buildDoctorJson(ctx) {
2110
2168
  const concurrently = ctx.args.noConcurrently !== true;
2111
- return {
2169
+ const out = {
2112
2170
  schemaVersion: 1,
2113
2171
  scannedTables: Object.keys(ctx.schema.tables).length,
2114
2172
  stats: {
@@ -2118,6 +2176,10 @@ function buildDoctorJson(ctx) {
2118
2176
  statsAgeDays: ctx.snapshot.statsAgeDays,
2119
2177
  notices: ctx.snapshot.notices,
2120
2178
  },
2179
+ heat: {
2180
+ available: ctx.heat.available,
2181
+ notice: ctx.heat.notice,
2182
+ },
2121
2183
  thresholds: STATS_THRESHOLDS,
2122
2184
  findings: ctx.findings.map((f) => ({
2123
2185
  table: f.missing.table,
@@ -2128,18 +2190,37 @@ function buildDoctorJson(ctx) {
2128
2190
  metrics: f.score.metrics,
2129
2191
  hotWarning: f.score.hotWarning,
2130
2192
  partialNotNull: f.score.partialNotNull,
2193
+ heatBoosted: f.score.heatBoosted,
2131
2194
  probes: f.missing.probes,
2132
2195
  createSql: doctorCreateSql(f, { concurrently }),
2133
2196
  dropSql: buildDropIndexSql(f.missing.indexName, { concurrently }),
2134
2197
  })),
2135
2198
  invalidIndexes: ctx.invalid,
2136
2199
  };
2200
+ // Additive: the drop-suggestion arrays appear only when --unused / --audit ran.
2201
+ if (ctx.subtract.unusedRan) {
2202
+ out.unused = ctx.subtract.unused;
2203
+ out.redundant = ctx.subtract.redundant;
2204
+ out.invalid = ctx.invalid;
2205
+ }
2206
+ if (ctx.subtract.auditRan) {
2207
+ out.audit = ctx.subtract.audit;
2208
+ }
2209
+ return out;
2137
2210
  }
2138
2211
  async function renderDoctorHuman(ctx) {
2139
- const { spinner, schema, findings, invalid, snapshot, usable, args, config } = ctx;
2212
+ const { spinner, schema, findings, invalid, snapshot, usable, heat, subtract, args, config } = ctx;
2140
2213
  spinner.succeed(`Scanned ${bold(String(Object.keys(schema.tables).length))} tables`);
2141
- if (findings.length === 0 && invalid.length === 0) {
2142
- success('Every relation probe is backed by an index, and no invalid indexes were found');
2214
+ const subtractRan = subtract.unusedRan || subtract.auditRan;
2215
+ const nothingToAdd = findings.length === 0 && invalid.length === 0;
2216
+ const nothingToSubtract = subtract.unused.length === 0 && subtract.redundant.length === 0 && subtract.audit.length === 0;
2217
+ if (nothingToAdd && (!subtractRan || nothingToSubtract)) {
2218
+ if (subtractRan) {
2219
+ success('No never-scanned or redundant indexes found, and no invalid indexes were found');
2220
+ }
2221
+ else {
2222
+ success('Every relation probe is backed by an index, and no invalid indexes were found');
2223
+ }
2143
2224
  newline();
2144
2225
  return;
2145
2226
  }
@@ -2155,8 +2236,20 @@ async function renderDoctorHuman(ctx) {
2155
2236
  else {
2156
2237
  renderTopologyFallback(findings, snapshot);
2157
2238
  }
2239
+ // Heat honesty: one line when the workload-heat boost could not be sourced.
2240
+ if (!heat.available && heat.notice) {
2241
+ console.log(` ${dim(`Note: ${heat.notice}`)}`);
2242
+ newline();
2243
+ }
2158
2244
  }
2159
2245
  renderInvalidIndexes(invalid);
2246
+ if (subtract.unusedRan) {
2247
+ renderUnusedIndexes(subtract.unused, subtract.minScans, snapshot);
2248
+ renderRedundantIndexes(subtract.redundant);
2249
+ }
2250
+ if (subtract.auditRan) {
2251
+ renderDoctorAudit(subtract.audit, subtract.minScans, snapshot);
2252
+ }
2160
2253
  if (findings.length > 0) {
2161
2254
  if (args.fix) {
2162
2255
  renderFixMigration(findings, config, args);
@@ -2168,6 +2261,65 @@ async function renderDoctorHuman(ctx) {
2168
2261
  }
2169
2262
  }
2170
2263
  }
2264
+ /** Shared caveat block: what an idx_scan of zero does and does not prove. */
2265
+ function renderUnusedCaveats(minScans, snapshot) {
2266
+ const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
2267
+ const threshold = minScans ?? STATS_THRESHOLDS.unusedMinScans;
2268
+ console.log(` ${dim(`Usage counters are since the last stats reset (${ageLabel} ago).`)}`);
2269
+ console.log(` ${dim(`Caveats: counters zero on a stats reset or crash; a read replica's index scans NEVER feed`)}`);
2270
+ console.log(` ${dim(`the primary's counters, so an index only a replica uses looks dead here. Threshold: idx_scan < ${threshold}.`)}`);
2271
+ console.log(` ${dim('Primary-key, unique, exclusion, and replica-identity indexes are excluded. Nothing here is auto-dropped.')}`);
2272
+ newline();
2273
+ }
2274
+ /** doctor --unused: never-scanned indexes with DROP suggestions (report-only). */
2275
+ function renderUnusedIndexes(unused, minScans, snapshot) {
2276
+ if (unused.length === 0)
2277
+ return;
2278
+ const total = unused.reduce((sum, u) => sum + (u.sizeBytes ?? 0), 0);
2279
+ warn(`Found ${bold(String(unused.length))} never-scanned index(es) (${formatBytes(total)} reclaimable).`);
2280
+ renderUnusedCaveats(minScans, snapshot);
2281
+ for (const u of unused) {
2282
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(u.table))} ${dim(`(${u.columns.join(', ') || '?'})`)} ${gray(`${u.indexName} · ${u.idxScan} scans · ${formatBytes(u.sizeBytes)}`)}`);
2283
+ console.log(` ${dim(symbols.teeEnd)} ${green(u.dropSql)}`);
2284
+ newline();
2285
+ }
2286
+ }
2287
+ /** doctor --unused: redundant leading-prefix indexes with DROP suggestions (report-only). */
2288
+ function renderRedundantIndexes(redundant) {
2289
+ if (redundant.length === 0)
2290
+ return;
2291
+ const total = redundant.reduce((sum, r) => sum + (r.sizeBytes ?? 0), 0);
2292
+ warn(`Found ${bold(String(redundant.length))} redundant index(es) (${formatBytes(total)} reclaimable).`);
2293
+ console.log(` ${dim('Each is a leading prefix of a wider index that already serves the same lookups. Report-only, never auto-dropped.')}`);
2294
+ newline();
2295
+ for (const r of redundant) {
2296
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(r.table))} ${dim(`(${r.columns.join(', ')})`)} ${gray(`${r.indexName} · ${formatBytes(r.sizeBytes)}`)}`);
2297
+ console.log(` ${dim(symbols.tee)} covered by ${blue(r.coveredBy)} ${dim(`(${r.coveredByColumns.join(', ')})`)}`);
2298
+ console.log(` ${dim(symbols.teeEnd)} ${green(r.dropSql)}`);
2299
+ newline();
2300
+ }
2301
+ }
2302
+ /** doctor --audit: doctor's own previously-suggested indexes now never scanned. */
2303
+ function renderDoctorAudit(audit, minScans, snapshot) {
2304
+ if (audit.length === 0) {
2305
+ success('No doctor-suggested index is going unused');
2306
+ newline();
2307
+ return;
2308
+ }
2309
+ warn(`doctor previously suggested these indexes; ${bold(String(audit.length))} have never been scanned since the stats reset.`);
2310
+ renderUnusedCaveats(minScans, snapshot);
2311
+ for (const a of audit) {
2312
+ const tag = a.ambiguous ? red(' [ambiguous: truncated name collides with another column set]') : '';
2313
+ console.log(` ${yellow(symbols.warning)} ${bold(cyan(a.table))} ${dim(`(${a.columns.join(', ') || '?'})`)} ${gray(`${a.indexName} · ${a.idxScan} scans · ${formatBytes(a.sizeBytes)}`)}${tag}`);
2314
+ if (a.ambiguous) {
2315
+ console.log(` ${dim(symbols.tee)} ${dim('the 63-byte name maps to more than one probe column set; confirm before dropping')}`);
2316
+ }
2317
+ console.log(` ${dim(symbols.teeEnd)} ${green(a.dropSql)}`);
2318
+ newline();
2319
+ }
2320
+ console.log(` ${dim('Consider dropping the ones you confirm are unused. Nothing here is auto-dropped.')}`);
2321
+ newline();
2322
+ }
2171
2323
  /** Cost-aware tiered output: three sections, each finding annotated with its numbers. */
2172
2324
  function renderTiers(findings, snapshot, _args) {
2173
2325
  const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
@@ -2692,7 +2844,7 @@ function showHelp() {
2692
2844
  console.log(` ${dim('status')} Show applied/pending migrations`);
2693
2845
  console.log(` ${cyan('seed')} Run seed file`);
2694
2846
  console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
2695
- console.log(` ${cyan('doctor')} Cost-aware missing-FK-index triage ${dim('(--fix, --json, --no-concurrently)')}`);
2847
+ console.log(` ${cyan('doctor')} Cost-aware missing-FK-index triage ${dim('(--fix, --json, --unused, --audit)')}`);
2696
2848
  console.log(` ${cyan('studio')} Launch local read-only web UI ${dim('(--write for writes, --demo for a sample DB)')}`);
2697
2849
  console.log(` ${cyan('mcp')} Start read-only MCP server over stdio`);
2698
2850
  console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
@@ -79,6 +79,25 @@ export declare function buildDropIndexSql(indexName: string, options?: {
79
79
  * counts as an index.
80
80
  */
81
81
  export declare function isProbeIndexed(meta: TableMetadata, columns: string[]): boolean;
82
+ /**
83
+ * The deterministic index name `doctor --fix` uses for a relation-probe index:
84
+ * `idx_<table>_<cols>` truncated to Postgres's 63-byte identifier limit. `doctor
85
+ * --audit` recomputes this to recognize its own previously-suggested indexes.
86
+ */
87
+ export declare function doctorIndexName(table: string, columns: string[]): string;
88
+ /** A (table, columns) pair whose probe maps to a given deterministic name. */
89
+ export interface DoctorProbeColumns {
90
+ table: string;
91
+ columns: string[];
92
+ }
93
+ /**
94
+ * Map every deterministic doctor index name the schema's relation probes would
95
+ * generate to the distinct column sets that produce it. A name that maps to more
96
+ * than one distinct column set is a post-truncation collision (very long column
97
+ * names slicing to the same 63 bytes): `doctor --audit` reports those as
98
+ * ambiguous rather than issuing a confident drop verdict.
99
+ */
100
+ export declare function collectDoctorProbeIndexNames(schema: SchemaMetadata): Map<string, DoctorProbeColumns[]>;
82
101
  /**
83
102
  * Scan every relation in the schema and return the probes with no index support,
84
103
  * deduplicated by (table, column set) with all contributing relations attached.
Binary file