@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 +18 -9
- package/package.json +1 -1
- package/src/cli.js +31 -42
- package/src/commands/indexes.js +379 -20
- package/src/commands/indexes.test.js +231 -0
- package/src/commands/index-recommendations.js +0 -426
- package/src/commands/index-recommendations.test.js +0 -323
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.
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
|
322
|
-
|
|
|
323
|
-
|
|
|
324
|
-
|
|
|
325
|
-
|
|
|
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
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, "
|
|
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:
|
|
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
|
-
|
|
274
|
-
["
|
|
275
|
-
["
|
|
276
|
-
["
|
|
277
|
-
["
|
|
278
|
-
["
|
|
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>",
|
|
282
|
-
["--
|
|
283
|
-
["--
|
|
284
|
-
["--
|
|
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:
|
|
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 ──────────────────────────────────────────────────────────
|
package/src/commands/indexes.js
CHANGED
|
@@ -1,31 +1,47 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* `deepsql indexes` — index
|
|
4
|
+
* `deepsql indexes` — unified index advisor namespace.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
9
|
+
* Advisor (workload-weighted, evidence-bearing — mirrors 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}.
|
|
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
|
-
//
|
|
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
|
}
|