@deepsql/mcp 0.14.0 → 0.16.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.
package/CLAUDE.md CHANGED
@@ -211,9 +211,10 @@ for them to say yes, then re-call with `confirmMutation: true`.
211
211
 
212
212
  **"What indexes should we add?"** → `get_index_recommendations`. Returns
213
213
  the workload-weighted top-N (default 5) with net benefit, contributing
214
- queries, and HypoPG cost-delta when available. Use `deepsql indexes
215
- missing` / `deepsql indexes health` in the terminal for catalog-level
216
- counterparts (usage stats, duplicates).
214
+ queries, and HypoPG cost-delta when available. The terminal equivalent
215
+ is `deepsql indexes top` (same data, same ranking); `deepsql indexes
216
+ missing` / `health` / `unused` / `duplicates` cover the catalog-level
217
+ diagnostics that complement the workload-weighted advisor.
217
218
 
218
219
  **"How much faster will this index actually make things?"** →
219
220
  `apply_index_recommendation` with `mode: "DRY_RUN"` (default). On
@@ -225,7 +226,10 @@ the index (CONCURRENTLY) and run `EXPLAIN ANALYZE` before/after.
225
226
 
226
227
  **"What changed recently / what should I worry about?"** → Tell the user to
227
228
  run `deepsql digest` (today) or `deepsql digest 7` (last seven). The digest
228
- isn't MCP-exposed.
229
+ isn't MCP-exposed. The digest now includes a workload-weighted **Index Wins**
230
+ section that surfaces the same top-N recommendations `get_index_recommendations`
231
+ returns, with the `deepsql indexes apply <id> --mode dry-run` CTA — so a user
232
+ who skims the digest in Slack can flow directly into the apply path.
229
233
 
230
234
  **"Are there foreign keys between X and Y?"** → `get_relationships`. Many
231
235
  real-world DBs lack declared FKs but DeepSQL's brain infers them; check the
@@ -318,11 +322,16 @@ them at the terminal command rather than trying to fake it through
318
322
 
319
323
  | Capability | CLI command |
320
324
  |---|---|
321
- | Catalog-level index stats (counterpart to `get_index_recommendations`) | `deepsql indexes list`, `deepsql indexes missing` |
322
- | Unused / duplicate index detection | `deepsql indexes unused`, `deepsql indexes duplicates` |
323
- | Per-table index usage stats | `deepsql indexes usage <table>` |
324
- | Index health report | `deepsql indexes health` |
325
- | Workload-weighted advisor + apply (DBA-grade) | `deepsql index-recommendations top` / `apply <id>` — same surface as the MCP `get_index_recommendations` + `apply_index_recommendation` tools, useful from a terminal |
325
+ | Workload-weighted advisor (terminal mirror of `get_index_recommendations`) | `deepsql indexes top [--limit N]` |
326
+ | Apply / dry-run an advisor recommendation (terminal mirror of `apply_index_recommendation`) | `deepsql indexes apply <id> [--mode dry-run\|apply\|apply-and-measure] [--confirm]` |
327
+ | Force a fresh accumulation cycle | `deepsql indexes refresh` |
328
+ | Full recommendation detail incl. contributing queries | `deepsql indexes show <id>` |
329
+ | Dismiss a recommendation | `deepsql indexes dismiss <id>` |
330
+ | Browse all recommendations (any status) | `deepsql indexes list [--all\|--status …]` |
331
+ | Catalog: missing-index suggestions | `deepsql indexes missing` |
332
+ | Catalog: unused / duplicate index detection | `deepsql indexes unused`, `deepsql indexes duplicates` |
333
+ | Catalog: per-table index usage stats | `deepsql indexes usage <table>` |
334
+ | Catalog: index health report | `deepsql indexes health` |
326
335
  | Daily digest (anomalies + AI commentary) | `deepsql digest`, `deepsql digest 7` |
327
336
  | Streaming AI optimization for a slow query | `deepsql slow-queries optimize --query-id <id>` |
328
337
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepsql/mcp",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
5
5
  "bin": {
6
6
  "deepsql": "bin/deepsql.js",
package/src/cli.js CHANGED
@@ -36,7 +36,6 @@ const COMMANDS = {
36
36
  access: () => require("./commands/access"),
37
37
  permissions: () => require("./commands/permissions"),
38
38
  "slow-queries": () => require("./commands/slow-queries"),
39
- "index-recommendations": () => require("./commands/index-recommendations"),
40
39
  setup: () => require("./commands/setup"),
41
40
  };
42
41
 
@@ -60,8 +59,7 @@ const COMMAND_LIST = [
60
59
  ["business-rules", false, "List active business rules and SQL guardrails"],
61
60
  ["relationships", false, "List inferred and validated FK relationships"],
62
61
  ["anti-patterns", false, "List schema- or query-level anti-patterns"],
63
- ["indexes", true, "Index suggestions, usage, and health (read-only)"],
64
- ["index-recommendations", true, "Workload-weighted index advisor + HypoPG-validated apply tool"],
62
+ ["indexes", true, "Workload-weighted index advisor + catalog diagnostics (top, apply, list, missing, health, unused, duplicates, usage)"],
65
63
  ["users", true, "Manage workspace users (admin)"],
66
64
  ["access", true, "Manage per-connection access grants (admin)"],
67
65
  ["permissions", true, "Manage role-based permission overrides (admin)"],
@@ -267,23 +265,41 @@ const COMMAND_HELP = {
267
265
  },
268
266
 
269
267
  indexes: {
270
- description: "Index suggestions, usage, and health — read-only in V1.",
268
+ description:
269
+ "Unified index advisor — workload-weighted recommendations + catalog diagnostics. " +
270
+ "`apply` is the one mutation; dry-run is the default and uses HypoPG on Postgres " +
271
+ "to estimate cost-delta without writes. Write modes (apply / apply-and-measure) " +
272
+ "require --confirm.",
271
273
  usage: "deepsql indexes <subcommand> [options]",
272
274
  subcommands: [
273
- ["list [--all] [--status <s>]", "Index recommendations (defaults to PENDING)"],
274
- ["missing", "Missing-index suggestions from the advisor"],
275
- ["health", "Comprehensive index health report"],
276
- ["unused", "Indexes the engine has not used"],
277
- ["duplicates", "Duplicate or redundant indexes"],
278
- ["usage <table>", "Per-table index usage statistics"],
275
+ // Advisor (workload-weighted)
276
+ ["top [--limit N]", "Pre-computed top-N pending recommendations (default 5, max 50) — ranked by net benefit, with HypoPG cost-delta + contributing-query evidence"],
277
+ ["list [--all] [--status PENDING|APPLIED|DISMISSED]", "All recommendations (defaults to PENDING) — simpler view than `top`"],
278
+ ["show <id> --connection <name>", "Full detail: workload, write-cost, HypoPG cost, contributing queries"],
279
+ ["refresh", "Force a fresh accumulation cycle (POST /generate)"],
280
+ ["apply <id> [--mode <m>] [--confirm] [--no-concurrent]", "Run or dry-run a recommendation with before/after measurement"],
281
+ ["dismiss <id>", "Mark a recommendation as DISMISSED"],
282
+ // Catalog diagnostics
283
+ ["missing", "Missing-index suggestions from the catalog advisor"],
284
+ ["health", "Comprehensive index health report"],
285
+ ["unused", "Indexes the engine has not used (live pg_stat_user_indexes / sys.* probe)"],
286
+ ["duplicates", "Duplicate or redundant indexes"],
287
+ ["usage <table>", "Per-table index usage statistics"],
279
288
  ],
280
289
  options: [
281
- ["--connection <name>", "Connection to inspect"],
282
- ["--all", "Include APPLIED and DISMISSED in `list`"],
283
- ["--status PENDING|APPLIED|DISMISSED", "Filter `list` by status"],
284
- ["--json", "Raw JSON output"],
290
+ ["--connection <name>", "Connection to inspect"],
291
+ ["--limit <n>", "top: number of recommendations (default 5, max 50)"],
292
+ ["--all", "list: include APPLIED and DISMISSED rows"],
293
+ ["--status PENDING|APPLIED|DISMISSED", "list: filter by status"],
294
+ ["--mode dry-run|apply|apply-and-measure", "apply: defaults to dry-run (HypoPG; no writes)"],
295
+ ["--confirm", "apply: required for apply / apply-and-measure (write modes)"],
296
+ ["--no-concurrent", "apply: skip CREATE/DROP INDEX CONCURRENTLY (small dev tables)"],
297
+ ["--json", "Raw backend JSON instead of the terminal-friendly table"],
285
298
  ],
286
- notes: "V1 is read-only. Apply, dismiss, delete, and generate are not exposed by the CLI yet.",
299
+ notes:
300
+ "Mirrors the MCP `get_index_recommendations` + `apply_index_recommendation` tools — " +
301
+ "use this when you're at a terminal, the MCP tools when you're inside an AI client. " +
302
+ "Same backend, same data, same safety contract.",
287
303
  },
288
304
 
289
305
  users: {
@@ -363,33 +379,6 @@ const COMMAND_HELP = {
363
379
  notes: "Org and LLM config are set at install time and are NOT touched by this wizard.",
364
380
  },
365
381
 
366
- "index-recommendations": {
367
- description:
368
- "DBA-grade workload-weighted index advisor. Default `top` shows pending " +
369
- "recommendations ranked by net benefit (workload − write-cost). `apply` " +
370
- "is the only mutation — dry-run uses HypoPG (Postgres-only) so no writes " +
371
- "leak; the two write modes require --confirm.",
372
- usage:
373
- "deepsql index-recommendations <top|list|show|refresh|apply|dismiss> [options]",
374
- subcommands: [
375
- ["top --connection <name> [--limit N]", "Pre-computed top-N (default 5, max 50), evidence-bearing"],
376
- ["list --connection <name>", "All pending recommendations for the connection"],
377
- ["show <id> --connection <name>", "Full detail: workload, HypoPG cost, contributing queries"],
378
- ["refresh --connection <name>", "Force a fresh accumulation cycle (POST /generate)"],
379
- ["apply <id> [--mode <m>] [--confirm]", "Run / dry-run with before/after measurement"],
380
- ["dismiss <id>", "Mark a recommendation as DISMISSED"],
381
- ],
382
- options: [
383
- ["--limit <n>", "top: number of recommendations (default 5, max 50)"],
384
- ["--mode <m>", "apply: dry-run (default) | apply | apply-and-measure"],
385
- ["--confirm", "apply: required for apply / apply-and-measure (write modes)"],
386
- ["--no-concurrent", "apply: skip CREATE/DROP INDEX CONCURRENTLY (dev / small tables)"],
387
- ["--json", "Emit raw backend JSON instead of the terminal-friendly table"],
388
- ],
389
- notes:
390
- "Complements `deepsql indexes` (catalog-level usage / health). " +
391
- "This namespace surfaces the workload-weighted advisor + the apply tool.",
392
- },
393
382
  };
394
383
 
395
384
  // ─── Color helpers ──────────────────────────────────────────────────────────
@@ -1,31 +1,47 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * `deepsql indexes` — index suggestions, usage, and health (read-only V1).
4
+ * `deepsql indexes` — unified index advisor namespace.
5
5
  *
6
- * Subcommands:
7
- * list [--all | --status PENDING|APPLIED|DISMISSED]
8
- * GET /index-recommendations/{cid} (or /pending/{cid})
9
- * missing GET /advisor/indexes/{cid} (missing-index advisor)
10
- * health GET /index-advisor/{cid}/health-report
11
- * unused GET /index-advisor/{cid}/unused
12
- * duplicates GET /index-advisor/{cid}/duplicates
13
- * usage <table> GET /index-advisor/{cid}/usage/{tableName}
6
+ * Two categories of subcommand live here, sharing the same `--connection` /
7
+ * `--json` flag plumbing:
14
8
  *
15
- * Everything is read-only by design V1 surfaces suggestions only. Apply,
16
- * dismiss, delete, generate, and the estimate-create/estimate-drop endpoints
17
- * are intentionally not wired up here; they'll land in a later pass once we
18
- * have UX for confirming mutations.
9
+ * Advisor (workload-weighted, evidence-bearingmirrors the MCP tools):
10
+ * top [--limit N] GET /index-recommendations/{cid}/top
11
+ * list [--all | --status PENDING|APPLIED|…] GET /index-recommendations/{cid}[?status=]
12
+ * show <id> GET /index-recommendations/{cid}/top (client-side filter)
13
+ * refresh POST /index-recommendations/generate/{cid}
14
+ * apply <id> [--mode … --confirm --no-concurrent] POST /index-recommendations/{id}/apply
15
+ * dismiss <id> PUT /index-recommendations/{id}/dismiss
16
+ *
17
+ * Catalog diagnostics (read-only — live pg_stat_* / sys.* probes):
18
+ * missing GET /advisor/indexes/{cid}
19
+ * health GET /index-advisor/{cid}/health-report
20
+ * unused GET /index-advisor/{cid}/unused
21
+ * duplicates GET /index-advisor/{cid}/duplicates
22
+ * usage <table> GET /index-advisor/{cid}/usage/{tableName}
23
+ *
24
+ * The `apply` subcommand is the one mutation here. Default mode is dry-run
25
+ * (HypoPG-based, no writes on Postgres). `apply` / `apply-and-measure`
26
+ * require `--confirm`; `--no-concurrent` opts out of CREATE/DROP INDEX
27
+ * CONCURRENTLY.
19
28
  */
20
29
 
21
- const { request } = require("../api/client");
30
+ const { ApiError, request } = require("../api/client");
22
31
  const { resolveSession } = require("./_session");
23
32
  const { resolveConnectionId } = require("./_connections");
24
33
 
25
34
  const VALID_STATUSES = new Set(["PENDING", "APPLIED", "DISMISSED"]);
26
35
 
27
36
  const SUBCOMMANDS = {
37
+ // Advisor surface (workload-weighted)
38
+ top: cmdTop,
28
39
  list: cmdList,
40
+ show: cmdShow,
41
+ refresh: cmdRefresh,
42
+ apply: cmdApply,
43
+ dismiss: cmdDismiss,
44
+ // Catalog diagnostics
29
45
  missing: cmdMissing,
30
46
  health: cmdHealth,
31
47
  unused: cmdUnused,
@@ -38,10 +54,101 @@ async function run(opts, io = {}) {
38
54
  const handler = SUBCOMMANDS[sub];
39
55
  if (!handler) {
40
56
  throw new Error(
41
- `Unknown indexes subcommand: ${sub}. Try \`list\`, \`missing\`, \`health\`, \`unused\`, \`duplicates\`, or \`usage <table>\`.`,
57
+ `Unknown indexes subcommand: ${sub}. ` +
58
+ `Try one of: top, list, show, refresh, apply, dismiss, ` +
59
+ `missing, health, unused, duplicates, usage <table>.`,
42
60
  );
43
61
  }
44
- return handler({ ...opts, positional: opts.positional.slice(1) }, io);
62
+ return wrap(handler)({ ...opts, positional: opts.positional.slice(1) }, io);
63
+ }
64
+
65
+ function wrap(handler) {
66
+ return async (opts, io) => {
67
+ try {
68
+ return await handler(opts, io);
69
+ } catch (err) {
70
+ if (err instanceof ApiError && err.status === 403) {
71
+ throw new Error(
72
+ "Access denied — index operations require permissions on this connection.",
73
+ );
74
+ }
75
+ if (err instanceof ApiError && err.status === 404) {
76
+ throw new Error(err.message || "Recommendation not found.");
77
+ }
78
+ throw err;
79
+ }
80
+ };
81
+ }
82
+
83
+ // ═════════════════════════════════════════════════════════════════════════════
84
+ // ADVISOR SURFACE — workload-weighted, evidence-bearing
85
+ // (mirrors the `get_index_recommendations` + `apply_index_recommendation` MCP tools)
86
+ // ═════════════════════════════════════════════════════════════════════════════
87
+
88
+ // ─── top ───────────────────────────────────────────────────────────────────
89
+
90
+ async function cmdTop(opts, { stdout = process.stdout } = {}) {
91
+ const session = resolveSession(opts);
92
+ const connectionId = await resolveConnectionId(session, opts.connection);
93
+ const limit = clampLimit(opts.limit, 5);
94
+ const result = await request(
95
+ session.baseUrl,
96
+ `/index-recommendations/${encodeURIComponent(connectionId)}/top`,
97
+ { token: session.token, query: { limit } },
98
+ );
99
+
100
+ if (opts.json) {
101
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
102
+ return;
103
+ }
104
+
105
+ const list = Array.isArray(result) ? result : [];
106
+ if (list.length === 0) {
107
+ stdout.write(
108
+ "No pending index recommendations. The 6-hour refresh scheduler may not have run yet, " +
109
+ "or the workload has none worth flagging. Try `deepsql indexes refresh` " +
110
+ "to force a fresh accumulation cycle.\n",
111
+ );
112
+ return;
113
+ }
114
+
115
+ stdout.write(`Top ${list.length} pending index recommendation(s):\n\n`);
116
+ list.forEach((r, idx) => {
117
+ const action = r.kind === "DROP_INDEX" ? "DROP" : "CREATE";
118
+ const target =
119
+ r.kind === "DROP_INDEX"
120
+ ? `${r.tableName}.${r.indexName} (unused)`
121
+ : `${r.tableName}(${r.columnNames})`;
122
+
123
+ const meta = [
124
+ r.priority,
125
+ r.occurrenceCount != null ? `seen ${r.occurrenceCount}×` : null,
126
+ formatNet(r),
127
+ r.evidenceCount > 0 ? `${r.evidenceCount} ev` : null,
128
+ ]
129
+ .filter(Boolean)
130
+ .join(", ");
131
+
132
+ stdout.write(`${idx + 1}. [${action}] ${target} — ${meta}\n`);
133
+ stdout.write(` id: ${r.id}\n`);
134
+ if (r.hypopgReductionPct != null) {
135
+ stdout.write(
136
+ ` HypoPG cost: ${formatCost(r.hypopgBeforeCost)} → ${formatCost(r.hypopgAfterCost)} ` +
137
+ `(${signedPct(r.hypopgReductionPct)})\n`,
138
+ );
139
+ }
140
+ if (r.reason) {
141
+ stdout.write(` ${truncate(r.reason, 200)}\n`);
142
+ }
143
+ if (Array.isArray(r.topEvidence) && r.topEvidence.length) {
144
+ const ev = r.topEvidence[0];
145
+ stdout.write(
146
+ ` top evidence: ${ev.calls} calls × ${formatMs(ev.meanExecTimeMs)}` +
147
+ ` mean = ${formatMs(ev.totalExecTimeMs)} total (${ev.role})\n`,
148
+ );
149
+ }
150
+ stdout.write("\n");
151
+ });
45
152
  }
46
153
 
47
154
  // ─── list ──────────────────────────────────────────────────────────────────
@@ -58,9 +165,6 @@ async function cmdList(opts, { stdout = process.stdout } = {}) {
58
165
  );
59
166
  }
60
167
 
61
- // `pending/{cid}` is a server-side filter for the common path; otherwise pull
62
- // the full list and filter client-side so a user can scope to APPLIED /
63
- // DISMISSED without a second backend route.
64
168
  const path = wantAll
65
169
  ? `/index-recommendations/${encodeURIComponent(connectionId)}`
66
170
  : `/index-recommendations/pending/${encodeURIComponent(connectionId)}`;
@@ -104,6 +208,125 @@ async function cmdList(opts, { stdout = process.stdout } = {}) {
104
208
  }
105
209
  }
106
210
 
211
+ // ─── show <id> ─────────────────────────────────────────────────────────────
212
+
213
+ async function cmdShow(opts, { stdout = process.stdout } = {}) {
214
+ const id = opts.positional[0];
215
+ if (!id) throw new Error("Usage: deepsql indexes show <id> --connection <name>");
216
+ const session = resolveSession(opts);
217
+ const connectionId = opts.connection
218
+ ? await resolveConnectionId(session, opts.connection)
219
+ : null;
220
+ if (!connectionId) {
221
+ throw new Error(
222
+ "`show` needs --connection <name> to look up the recommendation (or pin one with `deepsql connections use`).",
223
+ );
224
+ }
225
+ const list = await request(
226
+ session.baseUrl,
227
+ `/index-recommendations/${encodeURIComponent(connectionId)}/top`,
228
+ { token: session.token, query: { limit: 50 } },
229
+ );
230
+ const found = (list || []).find((r) => r.id === id);
231
+ if (!found) {
232
+ throw new Error(`Recommendation ${id} not in the top-50 for this connection.`);
233
+ }
234
+ if (opts.json) {
235
+ stdout.write(`${JSON.stringify(found, null, 2)}\n`);
236
+ return;
237
+ }
238
+ renderRecommendationDetail(stdout, found);
239
+ }
240
+
241
+ // ─── refresh ───────────────────────────────────────────────────────────────
242
+
243
+ async function cmdRefresh(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
244
+ const session = resolveSession(opts);
245
+ const connectionId = await resolveConnectionId(session, opts.connection);
246
+ stderr.write(`Refreshing index recommendations on ${opts.connection || connectionId}…\n`);
247
+ const result = await request(
248
+ session.baseUrl,
249
+ `/index-recommendations/generate/${encodeURIComponent(connectionId)}`,
250
+ { method: "POST", token: session.token, timeoutMs: 600000 },
251
+ );
252
+ if (opts.json) {
253
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
254
+ return;
255
+ }
256
+ if (result && result.success) {
257
+ stdout.write(
258
+ `Refresh complete: ${result.count} candidate(s) merged. ` +
259
+ `Use \`deepsql indexes top\` to see the top-N.\n`,
260
+ );
261
+ } else {
262
+ stdout.write(`Refresh response: ${JSON.stringify(result)}\n`);
263
+ }
264
+ }
265
+
266
+ // ─── apply <id> (the one mutation) ─────────────────────────────────────────
267
+
268
+ async function cmdApply(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
269
+ const id = opts.positional[0];
270
+ if (!id) {
271
+ throw new Error(
272
+ "Usage: deepsql indexes apply <id> [--mode dry-run|apply|apply-and-measure] [--confirm] [--no-concurrent]",
273
+ );
274
+ }
275
+
276
+ const mode = normalizeMode(opts.mode);
277
+ const isWriteMode = mode === "APPLY" || mode === "APPLY_AND_MEASURE";
278
+ if (isWriteMode && !opts.confirm) {
279
+ throw new Error(
280
+ `Mode ${mode} mutates the target database. Re-run with --confirm to proceed.`,
281
+ );
282
+ }
283
+ const concurrent = !opts.noConcurrent;
284
+
285
+ const session = resolveSession(opts);
286
+ if (isWriteMode) {
287
+ stderr.write(`Running ${mode} on recommendation ${id}…\n`);
288
+ }
289
+
290
+ const result = await request(
291
+ session.baseUrl,
292
+ `/index-recommendations/${encodeURIComponent(id)}/apply`,
293
+ {
294
+ method: "POST",
295
+ token: session.token,
296
+ timeoutMs: 600000,
297
+ query: { mode, confirm: opts.confirm === true, concurrent },
298
+ },
299
+ );
300
+
301
+ if (opts.json) {
302
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
303
+ return;
304
+ }
305
+ renderApplyResult(stdout, result);
306
+ }
307
+
308
+ // ─── dismiss <id> ──────────────────────────────────────────────────────────
309
+
310
+ async function cmdDismiss(opts, { stdout = process.stdout } = {}) {
311
+ const id = opts.positional[0];
312
+ if (!id) throw new Error("Usage: deepsql indexes dismiss <id>");
313
+ const session = resolveSession(opts);
314
+ const result = await request(
315
+ session.baseUrl,
316
+ `/index-recommendations/${encodeURIComponent(id)}/dismiss`,
317
+ { method: "PUT", token: session.token },
318
+ );
319
+ if (opts.json) {
320
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
321
+ return;
322
+ }
323
+ stdout.write(`Dismissed recommendation ${id}.\n`);
324
+ }
325
+
326
+ // ═════════════════════════════════════════════════════════════════════════════
327
+ // CATALOG DIAGNOSTICS — live pg_stat_* / sys.* probes
328
+ // ═════════════════════════════════════════════════════════════════════════════
329
+
107
330
  // ─── missing ───────────────────────────────────────────────────────────────
108
331
 
109
332
  async function cmdMissing(opts, { stdout = process.stdout } = {}) {
@@ -278,7 +501,92 @@ async function cmdUsage(opts, { stdout = process.stdout } = {}) {
278
501
  }
279
502
  }
280
503
 
281
- // ─── helpers ───────────────────────────────────────────────────────────────
504
+ // ═════════════════════════════════════════════════════════════════════════════
505
+ // helpers
506
+ // ═════════════════════════════════════════════════════════════════════════════
507
+
508
+ function renderRecommendationDetail(stdout, r) {
509
+ const action = r.kind === "DROP_INDEX" ? "DROP" : "CREATE";
510
+ stdout.write(`[${action}] ${r.tableName}(${r.columnNames || r.indexName})\n`);
511
+ stdout.write(` id : ${r.id}\n`);
512
+ stdout.write(` priority : ${r.priority}\n`);
513
+ stdout.write(` status : ${r.status || "PENDING"}\n`);
514
+ stdout.write(` occurrenceCnt : ${r.occurrenceCount}\n`);
515
+ stdout.write(` workloadScore : ${formatMs(r.workloadScoreMs)}\n`);
516
+ stdout.write(` writeCost : ${formatMs(r.writeCostScore)}\n`);
517
+ stdout.write(` netBenefit : ${formatMs(r.netBenefitMs)}\n`);
518
+ if (r.hypopgReductionPct != null) {
519
+ stdout.write(
520
+ ` HypoPG : ${formatCost(r.hypopgBeforeCost)} → ${formatCost(r.hypopgAfterCost)} ` +
521
+ `(${signedPct(r.hypopgReductionPct)})\n`,
522
+ );
523
+ }
524
+ stdout.write(` DDL : ${r.createStatement}\n`);
525
+ if (r.reason) stdout.write(` reason : ${r.reason}\n`);
526
+ if (Array.isArray(r.topEvidence) && r.topEvidence.length) {
527
+ stdout.write(` contributing queries (${r.topEvidence.length}):\n`);
528
+ for (const ev of r.topEvidence) {
529
+ stdout.write(
530
+ ` - fp=${(ev.fingerprint || "?").slice(0, 12)} calls=${ev.calls} mean=${formatMs(ev.meanExecTimeMs)} total=${formatMs(ev.totalExecTimeMs)} role=${ev.role}\n`,
531
+ );
532
+ if (ev.exampleSql) {
533
+ stdout.write(` ${truncate(ev.exampleSql, 200)}\n`);
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ function renderApplyResult(stdout, r) {
540
+ if (!r || typeof r !== "object") {
541
+ stdout.write("Apply returned no body.\n");
542
+ return;
543
+ }
544
+ const status = r.status || "?";
545
+ if (status === "BLOCKED_NEEDS_CONFIRMATION") {
546
+ stdout.write(`[${r.mode}] blocked — pass --confirm to mutate the database.\n`);
547
+ return;
548
+ }
549
+ if (status === "NOT_FOUND") {
550
+ stdout.write(`[${r.mode}] recommendation not found: ${r.recommendationId}\n`);
551
+ return;
552
+ }
553
+ if (status === "NO_USABLE_SAMPLES") {
554
+ stdout.write(
555
+ `[${r.mode}] no literal-bearing contributing queries available; cannot measure.\n`,
556
+ );
557
+ return;
558
+ }
559
+ if (status === "FAILED") {
560
+ stdout.write(`[${r.mode}] failed: ${r.message || "(no message)"}\n`);
561
+ return;
562
+ }
563
+
564
+ stdout.write(`[${r.mode}] ${status}\n`);
565
+ stdout.write(` DDL: ${r.executedDdl}\n`);
566
+ if (r.beforeCost != null && r.afterCost != null) {
567
+ stdout.write(
568
+ ` planner cost: ${formatCost(r.beforeCost)} → ${formatCost(r.afterCost)} ` +
569
+ `(${signedPct(r.costReductionPct)})\n`,
570
+ );
571
+ }
572
+ if (r.beforeWallTimeMs != null && r.afterWallTimeMs != null) {
573
+ stdout.write(
574
+ ` wall time: ${formatMs(r.beforeWallTimeMs)} → ${formatMs(r.afterWallTimeMs)} ` +
575
+ `(${signedPct(r.wallTimeImprovementPct)})\n`,
576
+ );
577
+ }
578
+ if (Array.isArray(r.samples) && r.samples.length) {
579
+ stdout.write(` ${r.samples.length} contributing query sample(s):\n`);
580
+ for (const s of r.samples.slice(0, 5)) {
581
+ stdout.write(
582
+ ` fp=${(s.fingerprint || "?").slice(0, 12)} cost ${formatCost(s.beforeCost)} → ${formatCost(s.afterCost)}` +
583
+ (s.error ? ` (error: ${s.error})` : "") +
584
+ "\n",
585
+ );
586
+ }
587
+ }
588
+ if (r.message) stdout.write(` ${r.message}\n`);
589
+ }
282
590
 
283
591
  function priorityWeight(p) {
284
592
  switch (String(p || "").toUpperCase()) {
@@ -290,6 +598,57 @@ function priorityWeight(p) {
290
598
  }
291
599
  }
292
600
 
601
+ function clampLimit(raw, fallback) {
602
+ const n = Number.parseInt(raw, 10);
603
+ if (!Number.isFinite(n)) return fallback;
604
+ return Math.min(50, Math.max(1, n));
605
+ }
606
+
607
+ function normalizeMode(raw) {
608
+ if (!raw) return "DRY_RUN";
609
+ // Accept dash-form (CLI convention) and underscore-form (API enum).
610
+ const m = String(raw).toUpperCase().replace(/-/g, "_");
611
+ if (!["DRY_RUN", "APPLY", "APPLY_AND_MEASURE"].includes(m)) {
612
+ throw new Error(`Unknown mode: ${raw}. Expected dry-run, apply, or apply-and-measure.`);
613
+ }
614
+ return m;
615
+ }
616
+
617
+ function formatMs(ms) {
618
+ if (ms == null || !Number.isFinite(Number(ms))) return "—";
619
+ const n = Number(ms);
620
+ if (n <= 0) return "0ms";
621
+ if (n >= 86_400_000) return `${(n / 86_400_000).toFixed(1)}d`;
622
+ if (n >= 3_600_000) return `${(n / 3_600_000).toFixed(1)}h`;
623
+ if (n >= 60_000) return `${(n / 60_000).toFixed(1)}m`;
624
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}s`;
625
+ return `${n.toFixed(n >= 10 ? 0 : 1)}ms`;
626
+ }
627
+
628
+ function formatCost(c) {
629
+ if (c == null || !Number.isFinite(Number(c))) return "—";
630
+ return Number(c).toFixed(0);
631
+ }
632
+
633
+ function signedPct(pct) {
634
+ if (pct == null || !Number.isFinite(Number(pct))) return "—";
635
+ const sign = pct >= 0 ? "−" : "+";
636
+ return `${sign}${Math.abs(pct).toFixed(1)}%`;
637
+ }
638
+
639
+ function formatNet(r) {
640
+ if (r.netBenefitMs != null && r.netBenefitMs > 0) {
641
+ return `net=${formatMs(r.netBenefitMs)} saved`;
642
+ }
643
+ if (r.estimatedImpact != null) return `impact ${r.estimatedImpact}`;
644
+ return null;
645
+ }
646
+
647
+ function truncate(s, max) {
648
+ if (!s) return s;
649
+ return s.length <= max ? s : `${s.slice(0, max)}…`;
650
+ }
651
+
293
652
  function oneLine(s) {
294
653
  return String(s).replace(/\s+/g, " ").trim();
295
654
  }