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.
@@ -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 Check relations for missing FK indexes (--fix emits migration)
18
+ * turbine doctor - Cost-aware missing-FK-index triage (--fix, --json, --no-concurrently)
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)
@@ -73,6 +73,7 @@ const node_path_1 = require("node:path");
73
73
  const node_url_1 = require("node:url");
74
74
  const generate_js_1 = require("../generate.js");
75
75
  const index_advisor_js_1 = require("../index-advisor.js");
76
+ const index_stats_js_1 = require("../index-stats.js");
76
77
  const introspect_js_1 = require("../introspect.js");
77
78
  const schema_sql_js_1 = require("../schema-sql.js");
78
79
  const config_js_1 = require("./config.js");
@@ -164,6 +165,12 @@ function parseArgs(argv = process.argv.slice(2)) {
164
165
  case '--fix':
165
166
  result.fix = true;
166
167
  break;
168
+ case '--json':
169
+ result.json = true;
170
+ break;
171
+ case '--no-concurrently':
172
+ result.noConcurrently = true;
173
+ break;
167
174
  case '--zod':
168
175
  result.zod = true;
169
176
  break;
@@ -1571,12 +1578,24 @@ async function cmdMigrateUp(args, config) {
1571
1578
  (0, ui_js_1.newline)();
1572
1579
  }
1573
1580
  const spinner = new ui_js_1.Spinner('Applying migrations').start();
1581
+ // A no-transaction migration (CREATE INDEX CONCURRENTLY) can wait a long time
1582
+ // on other transactions and otherwise looks hung. Stop the spinner and print
1583
+ // a loud notice the moment one is about to run.
1584
+ const onNoTransaction = (file) => {
1585
+ spinner.stop();
1586
+ (0, ui_js_1.warn)(`Running ${(0, ui_js_1.bold)(file.filename)} WITHOUT a transaction (-- turbine:no-transaction).`);
1587
+ console.log(` ${(0, ui_js_1.dim)('Each statement runs on its own. A mid-file failure leaves earlier statements')}`);
1588
+ console.log(` ${(0, ui_js_1.dim)('applied and the migration UNRECORDED, so every statement must be idempotent.')}`);
1589
+ console.log(` ${(0, ui_js_1.dim)('CREATE INDEX CONCURRENTLY can wait on long-running transactions: not a hang.')}`);
1590
+ (0, ui_js_1.newline)();
1591
+ };
1574
1592
  let result;
1575
1593
  try {
1576
1594
  result = await (0, migrate_js_1.migrateUp)(url, config.migrationsDir, {
1577
1595
  step: args.step,
1578
1596
  allowDrift: args.allowDrift,
1579
1597
  allowDestructive: args.allowDestructive,
1598
+ onNoTransaction,
1580
1599
  });
1581
1600
  }
1582
1601
  catch (err) {
@@ -1593,6 +1612,7 @@ async function cmdMigrateUp(args, config) {
1593
1612
  step: args.step,
1594
1613
  allowDrift: args.allowDrift,
1595
1614
  allowDestructive: true,
1615
+ onNoTransaction,
1596
1616
  });
1597
1617
  }
1598
1618
  if (result.applied.length === 0 && result.errors.length === 0) {
@@ -2038,16 +2058,50 @@ async function cmdStatus(_args, config) {
2038
2058
  (0, ui_js_1.newline)();
2039
2059
  }
2040
2060
  }
2041
- // ---------------------------------------------------------------------------
2042
- // Command: doctor relation/index health check
2043
- // ---------------------------------------------------------------------------
2061
+ /** Human labels + tier ordering for the three triage buckets. */
2062
+ const TIER_ORDER = ['take-freely', 'take-deliberately', 'scrutinize'];
2063
+ const TIER_LABEL = {
2064
+ 'take-freely': 'TAKE FREELY',
2065
+ 'take-deliberately': 'TAKE DELIBERATELY',
2066
+ scrutinize: 'SCRUTINIZE',
2067
+ };
2068
+ /** Build the `CREATE INDEX` for a finding, honoring the partial-null suggestion. */
2069
+ function doctorCreateSql(f, opts) {
2070
+ return (0, index_advisor_js_1.buildCreateIndexSql)(f.missing.table, f.missing.columns, f.missing.indexName, {
2071
+ concurrently: opts.concurrently,
2072
+ partialNotNull: f.score.partialNotNull,
2073
+ });
2074
+ }
2075
+ /** The commented recipe prepended to a CONCURRENTLY fix migration's UP body. */
2076
+ const CONCURRENTLY_RECIPE_COMMENT = [
2077
+ '-- CREATE INDEX CONCURRENTLY builds without holding a write lock, but it cannot',
2078
+ '-- run inside a transaction (that is why this file carries the',
2079
+ '-- "-- turbine:no-transaction" directive above). Read before applying:',
2080
+ '--',
2081
+ '-- 1. Idempotency is required. This migration is recorded only after ALL',
2082
+ '-- statements succeed; a mid-file failure leaves earlier indexes built and',
2083
+ '-- the migration unrecorded, so a rerun must be safe. IF NOT EXISTS keeps',
2084
+ '-- each CREATE idempotent.',
2085
+ '-- 2. The INVALID-index trap. A CREATE INDEX CONCURRENTLY that fails partway',
2086
+ '-- leaves an INVALID index behind. On rerun, IF NOT EXISTS SKIPS that',
2087
+ '-- corpse (the name already exists), so the index is never actually built.',
2088
+ '-- Fix: DROP INDEX CONCURRENTLY the invalid index, then rerun. Run',
2089
+ '-- "turbine doctor" to list invalid indexes.',
2090
+ '-- 3. Locking. CREATE INDEX CONCURRENTLY waits for every transaction that can',
2091
+ '-- see the table to finish. A long-running transaction makes it wait and',
2092
+ '-- makes "migrate up" look hung. For bounded waits, SET lock_timeout /',
2093
+ '-- statement_timeout in a psql session.',
2094
+ ].join('\n');
2044
2095
  async function cmdDoctor(args, config) {
2045
- (0, ui_js_1.banner)();
2096
+ const jsonMode = args.json === true;
2046
2097
  const url = requireUrl(config);
2047
- (0, ui_js_1.label)('Database', (0, ui_js_1.redactUrl)(url));
2048
- (0, ui_js_1.label)('Schema', config.schema);
2049
- (0, ui_js_1.newline)();
2050
- const spinner = new ui_js_1.Spinner('Introspecting database').start();
2098
+ if (!jsonMode) {
2099
+ (0, ui_js_1.banner)();
2100
+ (0, ui_js_1.label)('Database', (0, ui_js_1.redactUrl)(url));
2101
+ (0, ui_js_1.label)('Schema', config.schema);
2102
+ (0, ui_js_1.newline)();
2103
+ }
2104
+ const spinner = jsonMode ? null : new ui_js_1.Spinner('Introspecting database').start();
2051
2105
  const schema = await (0, introspect_js_1.introspect)({
2052
2106
  connectionString: url,
2053
2107
  schema: config.schema,
@@ -2055,62 +2109,201 @@ async function cmdDoctor(args, config) {
2055
2109
  exclude: config.exclude.length ? config.exclude : undefined,
2056
2110
  });
2057
2111
  const missing = (0, index_advisor_js_1.findMissingRelationIndexes)(schema);
2058
- if (missing.length === 0) {
2059
- spinner.succeed('Every relation probe is backed by an index');
2060
- (0, ui_js_1.newline)();
2112
+ // Collect live statistics. The collector reads whole-schema indexes (for
2113
+ // invalid-index detection) plus per-table stats + probed-column null_frac.
2114
+ const probedTables = [...new Set(missing.map((m) => m.table))];
2115
+ const probedColumns = [];
2116
+ for (const m of missing) {
2117
+ if (m.columns.length === 1 && m.columns[0] !== undefined) {
2118
+ probedColumns.push({ table: m.table, column: m.columns[0] });
2119
+ }
2120
+ }
2121
+ let snapshot;
2122
+ try {
2123
+ snapshot = await (0, index_stats_js_1.collectStatsSnapshot)({
2124
+ connectionString: url,
2125
+ schema: config.schema,
2126
+ tables: probedTables,
2127
+ columns: probedColumns,
2128
+ });
2129
+ }
2130
+ catch (err) {
2131
+ snapshot = {
2132
+ available: false,
2133
+ statsReset: null,
2134
+ statsAgeDays: null,
2135
+ tables: {},
2136
+ indexes: [],
2137
+ nullFrac: {},
2138
+ notices: [`statistics collection failed: ${err instanceof Error ? err.message : String(err)}`],
2139
+ };
2140
+ }
2141
+ const invalid = (0, index_stats_js_1.findInvalidIndexes)(snapshot);
2142
+ 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) }));
2144
+ if (jsonMode) {
2145
+ spinner?.stop();
2146
+ console.log(JSON.stringify(buildDoctorJson({ schema, findings, invalid, snapshot, usable, args }), null, 2));
2061
2147
  return;
2062
2148
  }
2149
+ await renderDoctorHuman({ spinner: spinner, schema, findings, invalid, snapshot, usable, args, config });
2150
+ }
2151
+ /** The stable, versioned JSON contract (schemaVersion: 1). First external consumer: BataDB import. */
2152
+ function buildDoctorJson(ctx) {
2153
+ const concurrently = ctx.args.noConcurrently !== true;
2154
+ return {
2155
+ schemaVersion: 1,
2156
+ scannedTables: Object.keys(ctx.schema.tables).length,
2157
+ stats: {
2158
+ available: ctx.snapshot.available,
2159
+ usable: ctx.usable,
2160
+ statsReset: ctx.snapshot.statsReset ? ctx.snapshot.statsReset.toISOString() : null,
2161
+ statsAgeDays: ctx.snapshot.statsAgeDays,
2162
+ notices: ctx.snapshot.notices,
2163
+ },
2164
+ thresholds: index_stats_js_1.STATS_THRESHOLDS,
2165
+ findings: ctx.findings.map((f) => ({
2166
+ table: f.missing.table,
2167
+ columns: f.missing.columns,
2168
+ indexName: f.missing.indexName,
2169
+ tier: f.score.tier,
2170
+ reasons: f.score.reasons,
2171
+ metrics: f.score.metrics,
2172
+ hotWarning: f.score.hotWarning,
2173
+ partialNotNull: f.score.partialNotNull,
2174
+ probes: f.missing.probes,
2175
+ createSql: doctorCreateSql(f, { concurrently }),
2176
+ dropSql: (0, index_advisor_js_1.buildDropIndexSql)(f.missing.indexName, { concurrently }),
2177
+ })),
2178
+ invalidIndexes: ctx.invalid,
2179
+ };
2180
+ }
2181
+ async function renderDoctorHuman(ctx) {
2182
+ const { spinner, schema, findings, invalid, snapshot, usable, args, config } = ctx;
2063
2183
  spinner.succeed(`Scanned ${(0, ui_js_1.bold)(String(Object.keys(schema.tables).length))} tables`);
2064
- (0, ui_js_1.warn)(`Found ${(0, ui_js_1.bold)(String(missing.length))} unindexed relation probe(s)`);
2065
- (0, ui_js_1.newline)();
2066
- // Row counts put the findings in severity order: a missing index on a 300-row
2067
- // table is noise; on a 300K-row table it is the whole page load.
2068
- const rowCounts = new Map();
2069
- {
2070
- const { Pool } = (await Promise.resolve().then(() => __importStar(require('pg')))).default;
2071
- const pool = new Pool({ connectionString: url, max: 1 });
2072
- try {
2073
- const tables = [...new Set(missing.map((m) => m.table))];
2074
- const res = await pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
2075
- FROM pg_class c
2076
- JOIN pg_namespace n ON n.oid = c.relnamespace
2077
- WHERE n.nspname = $1 AND c.relname = ANY($2)`, [config.schema, tables]);
2078
- for (const row of res.rows)
2079
- rowCounts.set(row.relname, Math.max(0, Number(row.reltuples)));
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');
2186
+ (0, ui_js_1.newline)();
2187
+ return;
2188
+ }
2189
+ if (findings.length > 0) {
2190
+ (0, ui_js_1.warn)(`Found ${(0, ui_js_1.bold)(String(findings.length))} unindexed relation probe(s)`);
2191
+ (0, ui_js_1.newline)();
2192
+ console.log(` ${(0, ui_js_1.dim)('Turbine loads relations as correlated subqueries: the child table is probed')}`);
2193
+ console.log(` ${(0, ui_js_1.dim)('once per parent row, so an unindexed FK costs a full table scan PER PARENT.')}`);
2194
+ (0, ui_js_1.newline)();
2195
+ if (usable) {
2196
+ renderTiers(findings, snapshot, args);
2080
2197
  }
2081
- finally {
2082
- await pool.end();
2198
+ else {
2199
+ renderTopologyFallback(findings, snapshot);
2083
2200
  }
2084
2201
  }
2085
- missing.sort((a, b) => (rowCounts.get(b.table) ?? 0) - (rowCounts.get(a.table) ?? 0));
2086
- console.log(` ${(0, ui_js_1.dim)('Turbine loads relations as correlated subqueries — the child table is probed')}`);
2087
- console.log(` ${(0, ui_js_1.dim)('once per parent row, so an unindexed FK costs a full table scan PER PARENT.')}`);
2088
- (0, ui_js_1.newline)();
2089
- for (const m of missing) {
2090
- const rows = rowCounts.get(m.table);
2091
- const rowsLabel = rows !== undefined ? `~${rows.toLocaleString()} rows` : 'row count unknown';
2092
- console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(m.table))} ${(0, ui_js_1.dim)(`(${m.columns.join(', ')})`)} ${(0, ui_js_1.gray)(rowsLabel)}`);
2093
- for (const p of m.probes) {
2094
- console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} probed by ${p.from}.${(0, ui_js_1.blue)(p.relation)} ${(0, ui_js_1.dim)(`(${p.type})`)}`);
2202
+ renderInvalidIndexes(invalid);
2203
+ if (findings.length > 0) {
2204
+ if (args.fix) {
2205
+ renderFixMigration(findings, config, args);
2095
2206
  }
2096
- console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(m.createSql)}`);
2207
+ else {
2208
+ console.log(` ${(0, ui_js_1.dim)('Generate a fix migration with:')} ${(0, ui_js_1.cyan)('npx turbine doctor --fix')}`);
2209
+ console.log(` ${(0, ui_js_1.dim)('Machine-readable report:')} ${(0, ui_js_1.cyan)('npx turbine doctor --json')}`);
2210
+ (0, ui_js_1.newline)();
2211
+ }
2212
+ }
2213
+ }
2214
+ /** Cost-aware tiered output: three sections, each finding annotated with its numbers. */
2215
+ function renderTiers(findings, snapshot, _args) {
2216
+ const ageLabel = snapshot.statsAgeDays !== null ? `${Math.round(snapshot.statsAgeDays)}d` : 'unknown';
2217
+ console.log(` ${(0, ui_js_1.dim)(`Cost triage based on live statistics (stats reset ${ageLabel} ago). Thresholds: tiny < ${index_stats_js_1.STATS_THRESHOLDS.tinyTableRows.toLocaleString()} rows,`)}`);
2218
+ console.log(` ${(0, ui_js_1.dim)(`"real" write rate >= ${index_stats_js_1.STATS_THRESHOLDS.highWritesPerDay.toLocaleString()}/day, "many" indexes >= ${index_stats_js_1.STATS_THRESHOLDS.manyIndexes}.`)}`);
2219
+ (0, ui_js_1.newline)();
2220
+ const tierColor = {
2221
+ 'take-freely': ui_js_1.green,
2222
+ 'take-deliberately': ui_js_1.yellow,
2223
+ scrutinize: ui_js_1.red,
2224
+ };
2225
+ for (const tier of TIER_ORDER) {
2226
+ const inTier = findings
2227
+ .filter((f) => f.score.tier === tier)
2228
+ .sort((a, b) => b.score.benefitScore - a.score.benefitScore);
2229
+ if (inTier.length === 0)
2230
+ continue;
2231
+ console.log(` ${(0, ui_js_1.bold)(tierColor[tier](`${TIER_LABEL[tier]} (${inTier.length})`))}`);
2097
2232
  (0, ui_js_1.newline)();
2233
+ for (const f of inTier) {
2234
+ renderFinding(f, { concurrently: true, withReasons: true });
2235
+ }
2098
2236
  }
2099
- if (args.fix) {
2100
- const up = missing.map((m) => m.createSql).join('\n');
2101
- const down = missing.map((m) => m.dropSql).join('\n');
2102
- const file = (0, migrate_js_1.createMigration)(config.migrationsDir, 'add_relation_fk_indexes', { up, down });
2103
- (0, ui_js_1.success)(`Created migration: ${(0, ui_js_1.bold)(file.filename)}`);
2237
+ }
2238
+ /** Degraded output: today's size-sorted topology report when stats are absent/young. */
2239
+ function renderTopologyFallback(findings, snapshot) {
2240
+ (0, ui_js_1.warn)('Statistics unavailable or too young to score cost: showing size-sorted topology only.');
2241
+ for (const notice of snapshot.notices)
2242
+ console.log(` ${(0, ui_js_1.dim)(`- ${notice}`)}`);
2243
+ if (snapshot.statsAgeDays !== null && snapshot.statsAgeDays < index_stats_js_1.STATS_THRESHOLDS.minStatsAgeDays) {
2244
+ console.log(` ${(0, ui_js_1.dim)(`- statistics were reset less than ${index_stats_js_1.STATS_THRESHOLDS.minStatsAgeDays} day(s) ago; write rates are not yet meaningful.`)}`);
2245
+ }
2246
+ (0, ui_js_1.newline)();
2247
+ const sorted = [...findings].sort((a, b) => (b.score.metrics.rows ?? 0) - (a.score.metrics.rows ?? 0));
2248
+ for (const f of sorted) {
2249
+ renderFinding(f, { concurrently: false, withReasons: false });
2250
+ }
2251
+ }
2252
+ /** Render one finding: table + columns, probing relations, reasons, and the create SQL. */
2253
+ function renderFinding(f, opts) {
2254
+ const m = f.missing;
2255
+ const rows = f.score.metrics.rows;
2256
+ const rowsLabel = rows !== null ? `~${rows.toLocaleString()} rows` : 'row count unknown';
2257
+ const sizeLabel = f.score.metrics.sizeBytes !== null ? `, ${(0, index_stats_js_1.formatBytes)(f.score.metrics.sizeBytes)}` : '';
2258
+ console.log(` ${(0, ui_js_1.yellow)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(m.table))} ${(0, ui_js_1.dim)(`(${m.columns.join(', ')})`)} ${(0, ui_js_1.gray)(`${rowsLabel}${sizeLabel}`)}`);
2259
+ for (const p of m.probes) {
2260
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} probed by ${p.from}.${(0, ui_js_1.blue)(p.relation)} ${(0, ui_js_1.dim)(`(${p.type})`)}`);
2261
+ }
2262
+ if (opts.withReasons) {
2263
+ for (const reason of f.score.reasons) {
2264
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.tee)} ${(0, ui_js_1.dim)(reason)}`);
2265
+ }
2266
+ }
2267
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(doctorCreateSql(f, { concurrently: opts.concurrently }))}`);
2268
+ (0, ui_js_1.newline)();
2269
+ }
2270
+ /** The invalid-index report section (a failed CONCURRENTLY build leaves these behind). */
2271
+ function renderInvalidIndexes(invalid) {
2272
+ if (invalid.length === 0)
2273
+ return;
2274
+ (0, ui_js_1.warn)(`Found ${(0, ui_js_1.bold)(String(invalid.length))} INVALID index(es) (a failed CREATE INDEX CONCURRENTLY leaves these).`);
2275
+ console.log(` ${(0, ui_js_1.dim)('IF NOT EXISTS skips an invalid index on rerun, so it never rebuilds. Drop it, then rerun:')}`);
2276
+ (0, ui_js_1.newline)();
2277
+ for (const idx of invalid) {
2278
+ console.log(` ${(0, ui_js_1.red)(ui_js_1.symbols.warning)} ${(0, ui_js_1.bold)((0, ui_js_1.cyan)(idx.table))} ${(0, ui_js_1.dim)(`(${idx.columns.join(', ') || '?'})`)} ${(0, ui_js_1.gray)(idx.indexName)}`);
2279
+ console.log(` ${(0, ui_js_1.dim)(ui_js_1.symbols.teeEnd)} ${(0, ui_js_1.green)(idx.dropSql)}`);
2104
2280
  (0, ui_js_1.newline)();
2281
+ }
2282
+ }
2283
+ /** Write the --fix migration (CONCURRENTLY + directive by default; plain with --no-concurrently). */
2284
+ function renderFixMigration(findings, config, args) {
2285
+ const concurrently = args.noConcurrently !== true;
2286
+ const up = findings.map((f) => doctorCreateSql(f, { concurrently })).join('\n');
2287
+ const down = findings.map((f) => (0, index_advisor_js_1.buildDropIndexSql)(f.missing.indexName, { concurrently })).join('\n');
2288
+ let file;
2289
+ if (concurrently) {
2290
+ file = (0, migrate_js_1.createMigration)(config.migrationsDir, 'add_relation_fk_indexes', { up: `${CONCURRENTLY_RECIPE_COMMENT}\n\n${up}`, down }, { header: '-- turbine:no-transaction' });
2291
+ }
2292
+ else {
2293
+ file = (0, migrate_js_1.createMigration)(config.migrationsDir, 'add_relation_fk_indexes', { up, down });
2294
+ }
2295
+ (0, ui_js_1.success)(`Created migration: ${(0, ui_js_1.bold)(file.filename)}`);
2296
+ (0, ui_js_1.newline)();
2297
+ if (concurrently) {
2298
+ console.log(` ${(0, ui_js_1.dim)('This is a')} ${(0, ui_js_1.cyan)('no-transaction')} ${(0, ui_js_1.dim)('migration (CREATE INDEX CONCURRENTLY).')}`);
2105
2299
  console.log(` ${(0, ui_js_1.dim)('Review it, then apply with:')} ${(0, ui_js_1.cyan)('npx turbine migrate up')}`);
2106
- console.log(` ${(0, ui_js_1.dim)('Large, hot tables: consider running the statements manually with')} ${(0, ui_js_1.cyan)('CREATE INDEX CONCURRENTLY')}`);
2107
- console.log(` ${(0, ui_js_1.dim)('(cannot run inside a transaction, so it is not emitted in the migration).')}`);
2108
- (0, ui_js_1.newline)();
2300
+ console.log(` ${(0, ui_js_1.dim)('For a plain in-transaction migration instead, use')} ${(0, ui_js_1.cyan)('doctor --fix --no-concurrently')}`);
2109
2301
  }
2110
2302
  else {
2111
- console.log(` ${(0, ui_js_1.dim)('Generate a fix migration with:')} ${(0, ui_js_1.cyan)('npx turbine doctor --fix')}`);
2112
- (0, ui_js_1.newline)();
2303
+ console.log(` ${(0, ui_js_1.dim)('Review it, then apply with:')} ${(0, ui_js_1.cyan)('npx turbine migrate up')}`);
2304
+ console.log(` ${(0, ui_js_1.dim)('For large, hot tables prefer the CONCURRENTLY form (drop')} ${(0, ui_js_1.cyan)('--no-concurrently')}${(0, ui_js_1.dim)(').')}`);
2113
2305
  }
2306
+ (0, ui_js_1.newline)();
2114
2307
  }
2115
2308
  // ---------------------------------------------------------------------------
2116
2309
  // Loopback host gate (Studio / Observe)
@@ -2542,7 +2735,7 @@ function showHelp() {
2542
2735
  console.log(` ${(0, ui_js_1.dim)('status')} Show applied/pending migrations`);
2543
2736
  console.log(` ${(0, ui_js_1.cyan)('seed')} Run seed file`);
2544
2737
  console.log(` ${(0, ui_js_1.cyan)('status')} ${(0, ui_js_1.dim)('| info')} Show schema summary`);
2545
- console.log(` ${(0, ui_js_1.cyan)('doctor')} Check relations for missing FK indexes ${(0, ui_js_1.dim)('(--fix emits migration)')}`);
2738
+ console.log(` ${(0, ui_js_1.cyan)('doctor')} Cost-aware missing-FK-index triage ${(0, ui_js_1.dim)('(--fix, --json, --no-concurrently)')}`);
2546
2739
  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)')}`);
2547
2740
  console.log(` ${(0, ui_js_1.cyan)('mcp')} Start read-only MCP server over stdio`);
2548
2741
  console.log(` ${(0, ui_js_1.cyan)('observe')} Launch metrics dashboard ${(0, ui_js_1.dim)('(requires TURBINE_OBSERVE_URL)')}`);