@deepsql/mcp 0.14.0 → 0.17.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 +21 -10
- package/deepsql-phase1-lib.js +251 -0
- package/package.json +1 -1
- package/skills/SKILL_BODY.md +7 -0
- package/src/auth/store.js +3 -3
- package/src/cli.js +61 -42
- package/src/commands/growth.js +458 -0
- package/src/commands/growth.test.js +439 -0
- package/src/commands/indexes.js +379 -20
- package/src/commands/indexes.test.js +231 -0
- package/src/commands/mcp.js +9 -9
- package/src/commands/slow-queries.js +116 -1
- package/src/user-home.js +29 -0
- package/src/commands/index-recommendations.js +0 -426
- package/src/commands/index-recommendations.test.js +0 -323
package/src/commands/mcp.js
CHANGED
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
const fs = require("node:fs");
|
|
26
|
-
const os = require("node:os");
|
|
27
26
|
const path = require("node:path");
|
|
28
27
|
const { spawn, spawnSync } = require("node:child_process");
|
|
29
28
|
|
|
30
29
|
const { resolveSession } = require("./_session");
|
|
30
|
+
const { userHome } = require("../user-home");
|
|
31
31
|
|
|
32
32
|
const SKILL_HEADER_MARKER = "<!-- BEGIN DEEPSQL DBA CONSULT SKILL -->";
|
|
33
33
|
const SKILL_FOOTER_MARKER = "<!-- END DEEPSQL DBA CONSULT SKILL -->";
|
|
@@ -44,7 +44,7 @@ const EDITORS = {
|
|
|
44
44
|
// (the official CLI handles future storage changes for us). If
|
|
45
45
|
// `claude` isn't on PATH (e.g. CI, SSH boxes), we fall back to
|
|
46
46
|
// writing ~/.claude.json directly via the standard JSON merge.
|
|
47
|
-
path: () => path.join(
|
|
47
|
+
path: () => path.join(userHome(), ".claude.json"),
|
|
48
48
|
key: "mcpServers",
|
|
49
49
|
cli: {
|
|
50
50
|
binary: "claude",
|
|
@@ -59,7 +59,7 @@ const EDITORS = {
|
|
|
59
59
|
},
|
|
60
60
|
skill: {
|
|
61
61
|
kind: "file",
|
|
62
|
-
path: () => path.join(
|
|
62
|
+
path: () => path.join(userHome(), ".claude", "skills", "deepsql", "SKILL.md"),
|
|
63
63
|
frontmatter: () => [
|
|
64
64
|
"---",
|
|
65
65
|
"name: deepsql",
|
|
@@ -77,11 +77,11 @@ const EDITORS = {
|
|
|
77
77
|
},
|
|
78
78
|
"cursor": {
|
|
79
79
|
format: "json",
|
|
80
|
-
path: () => path.join(
|
|
80
|
+
path: () => path.join(userHome(), ".cursor", "mcp.json"),
|
|
81
81
|
key: "mcpServers",
|
|
82
82
|
skill: {
|
|
83
83
|
kind: "file",
|
|
84
|
-
path: () => path.join(
|
|
84
|
+
path: () => path.join(userHome(), ".cursor", "rules", "deepsql.mdc"),
|
|
85
85
|
frontmatter: () => [
|
|
86
86
|
"---",
|
|
87
87
|
"description: DeepSQL DBA consult — call DeepSQL's MCP tools BEFORE generating any DDL, migration, or non-trivial SQL. Get brain context, schema, business rules, and anti-patterns first; then narrate findings to the user before proposing schema.",
|
|
@@ -100,11 +100,11 @@ const EDITORS = {
|
|
|
100
100
|
},
|
|
101
101
|
"codex": {
|
|
102
102
|
format: "toml",
|
|
103
|
-
path: () => path.join(
|
|
103
|
+
path: () => path.join(userHome(), ".codex", "config.toml"),
|
|
104
104
|
key: "mcp_servers",
|
|
105
105
|
skill: {
|
|
106
106
|
kind: "agents-append", // Codex reads AGENTS.md; we append a guarded section
|
|
107
|
-
path: () => path.join(
|
|
107
|
+
path: () => path.join(userHome(), ".codex", "AGENTS.md"),
|
|
108
108
|
frontmatter: () => "",
|
|
109
109
|
},
|
|
110
110
|
},
|
|
@@ -589,12 +589,12 @@ function claudeDesktopPath() {
|
|
|
589
589
|
// Desktop isn't officially shipped on Linux yet but the pattern
|
|
590
590
|
// matches the XDG default).
|
|
591
591
|
if (process.platform === "darwin") {
|
|
592
|
-
return path.join(
|
|
592
|
+
return path.join(userHome(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
593
593
|
}
|
|
594
594
|
if (process.platform === "win32" && process.env.APPDATA) {
|
|
595
595
|
return path.join(process.env.APPDATA, "Claude", "claude_desktop_config.json");
|
|
596
596
|
}
|
|
597
|
-
return path.join(
|
|
597
|
+
return path.join(userHome(), ".config", "Claude", "claude_desktop_config.json");
|
|
598
598
|
}
|
|
599
599
|
|
|
600
600
|
module.exports = {
|
|
@@ -29,12 +29,17 @@ const SUBCOMMANDS = {
|
|
|
29
29
|
analyze: cmdAnalyze,
|
|
30
30
|
optimize: cmdOptimize,
|
|
31
31
|
delete: cmdDelete,
|
|
32
|
+
trends: cmdTrends,
|
|
33
|
+
regressions: cmdRegressions,
|
|
34
|
+
timeline: cmdTimeline,
|
|
32
35
|
};
|
|
33
36
|
|
|
34
37
|
async function run(opts, io = {}) {
|
|
35
38
|
const sub = opts.positional[0];
|
|
36
39
|
if (!sub) {
|
|
37
|
-
throw new Error(
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Usage: deepsql slow-queries "
|
|
42
|
+
+ "<latest|history|analyze|optimize|delete|trends|regressions|timeline> ...");
|
|
38
43
|
}
|
|
39
44
|
const handler = SUBCOMMANDS[sub];
|
|
40
45
|
if (!handler) throw new Error(`Unknown slow-queries subcommand: ${sub}.`);
|
|
@@ -275,6 +280,116 @@ function safeParse(text) {
|
|
|
275
280
|
}
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
// ─── trends ──────────────────────────────────────────────────────────────
|
|
284
|
+
// 30-day analytics: tracked queries, regressions, and per-query timelines.
|
|
285
|
+
|
|
286
|
+
async function cmdTrends(opts, { stdout = process.stdout } = {}) {
|
|
287
|
+
const session = resolveSession(opts);
|
|
288
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
289
|
+
const queries = await request(
|
|
290
|
+
session.baseUrl,
|
|
291
|
+
`/slow-query-analytics/${encodeURIComponent(connectionId)}/queries`,
|
|
292
|
+
{ token: session.token },
|
|
293
|
+
);
|
|
294
|
+
const items = Array.isArray(queries) ? queries : [];
|
|
295
|
+
if (opts.json) {
|
|
296
|
+
stdout.write(`${JSON.stringify(items, null, 2)}\n`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (items.length === 0) {
|
|
300
|
+
stdout.write("No tracked queries yet. The daily analysis runs at 01:30, "
|
|
301
|
+
+ "or trigger one now from the UI / API.\n");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
printTrendRows(stdout, items);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function cmdRegressions(opts, { stdout = process.stdout } = {}) {
|
|
308
|
+
const session = resolveSession(opts);
|
|
309
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
310
|
+
const minFactor = parseNum(opts["min-factor"]) || 1.5;
|
|
311
|
+
const rows = await request(
|
|
312
|
+
session.baseUrl,
|
|
313
|
+
`/slow-query-analytics/${encodeURIComponent(connectionId)}/regressions?minFactor=${minFactor}`,
|
|
314
|
+
{ token: session.token },
|
|
315
|
+
);
|
|
316
|
+
const items = Array.isArray(rows) ? rows : [];
|
|
317
|
+
if (opts.json) {
|
|
318
|
+
stdout.write(`${JSON.stringify(items, null, 2)}\n`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (items.length === 0) {
|
|
322
|
+
stdout.write(`No queries regressed by ${minFactor}x or more on the latest run.\n`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
printTable(stdout, [
|
|
326
|
+
{ key: "fingerprint", label: "QUERY ID" },
|
|
327
|
+
{ key: "factor", label: "SLOWDOWN" },
|
|
328
|
+
{ key: "meanMs", label: "MEAN MS" },
|
|
329
|
+
{ key: "calls", label: "CALLS" },
|
|
330
|
+
{ key: "sql", label: "QUERY" },
|
|
331
|
+
], items.map((r) => ({
|
|
332
|
+
fingerprint: trim(r.fingerprint || "", 14),
|
|
333
|
+
factor: r.regressionFactor != null ? `${r.regressionFactor.toFixed(2)}x` : "?",
|
|
334
|
+
meanMs: r.meanExecMs != null ? Math.round(r.meanExecMs).toString() : "?",
|
|
335
|
+
calls: String(r.callsDelta ?? "?"),
|
|
336
|
+
sql: trim(r.normalizedSql || "", 56),
|
|
337
|
+
})));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function cmdTimeline(opts, { stdout = process.stdout } = {}) {
|
|
341
|
+
const session = resolveSession(opts);
|
|
342
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
343
|
+
const fingerprint = opts.positional[0];
|
|
344
|
+
if (!fingerprint) {
|
|
345
|
+
throw new Error("Usage: deepsql slow-queries timeline <fingerprint> --connection <name>");
|
|
346
|
+
}
|
|
347
|
+
const points = await request(
|
|
348
|
+
session.baseUrl,
|
|
349
|
+
`/slow-query-analytics/${encodeURIComponent(connectionId)}`
|
|
350
|
+
+ `/timeline/${encodeURIComponent(fingerprint)}`,
|
|
351
|
+
{ token: session.token },
|
|
352
|
+
);
|
|
353
|
+
const items = Array.isArray(points) ? points : [];
|
|
354
|
+
if (opts.json) {
|
|
355
|
+
stdout.write(`${JSON.stringify(items, null, 2)}\n`);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (items.length === 0) {
|
|
359
|
+
stdout.write("No timeline data for that query fingerprint.\n");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
printTable(stdout, [
|
|
363
|
+
{ key: "day", label: "DAY" },
|
|
364
|
+
{ key: "calls", label: "CALLS" },
|
|
365
|
+
{ key: "meanMs", label: "MEAN MS" },
|
|
366
|
+
{ key: "maxMs", label: "MAX MS" },
|
|
367
|
+
{ key: "factor", label: "VS PREV" },
|
|
368
|
+
], items.map((p) => ({
|
|
369
|
+
day: String(p.day || ""),
|
|
370
|
+
calls: String(p.callsDelta ?? "?"),
|
|
371
|
+
meanMs: p.meanExecMs != null ? Math.round(p.meanExecMs).toString() : "?",
|
|
372
|
+
maxMs: p.maxExecMs != null ? Math.round(p.maxExecMs).toString() : "?",
|
|
373
|
+
factor: p.regressionFactor != null ? `${p.regressionFactor.toFixed(2)}x` : "—",
|
|
374
|
+
})));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function printTrendRows(stdout, items) {
|
|
378
|
+
printTable(stdout, [
|
|
379
|
+
{ key: "fingerprint", label: "QUERY ID" },
|
|
380
|
+
{ key: "meanMs", label: "MEAN MS" },
|
|
381
|
+
{ key: "calls", label: "CALLS" },
|
|
382
|
+
{ key: "factor", label: "VS PREV" },
|
|
383
|
+
{ key: "sql", label: "QUERY" },
|
|
384
|
+
], items.map((q) => ({
|
|
385
|
+
fingerprint: trim(q.fingerprint || "", 14),
|
|
386
|
+
meanMs: q.meanExecMs != null ? Math.round(q.meanExecMs).toString() : "?",
|
|
387
|
+
calls: String(q.callsDelta ?? "?"),
|
|
388
|
+
factor: q.regressionFactor != null ? `${q.regressionFactor.toFixed(2)}x` : "—",
|
|
389
|
+
sql: trim(q.normalizedSql || "", 54),
|
|
390
|
+
})));
|
|
391
|
+
}
|
|
392
|
+
|
|
278
393
|
function printHistory(stdout, items) {
|
|
279
394
|
const rows = items.map((h) => ({
|
|
280
395
|
id: String(h.id ?? h.historyId ?? ""),
|
package/src/user-home.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the real user's home directory, even when the CLI is run via sudo.
|
|
5
|
+
*
|
|
6
|
+
* Priority: HOME env var → SUDO_USER /etc/passwd lookup → os.homedir().
|
|
7
|
+
* The "/root" guard on HOME handles `sudo -H` (which sets HOME=/root) while
|
|
8
|
+
* still respecting `sudo -E` (which preserves the original HOME).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const os = require("node:os");
|
|
13
|
+
|
|
14
|
+
function userHome() {
|
|
15
|
+
if (process.env.HOME && process.env.HOME !== "/root") return process.env.HOME;
|
|
16
|
+
if (process.env.SUDO_USER) {
|
|
17
|
+
try {
|
|
18
|
+
const passwd = fs.readFileSync("/etc/passwd", "utf8");
|
|
19
|
+
const line = passwd.split("\n").find(l => l.startsWith(process.env.SUDO_USER + ":"));
|
|
20
|
+
if (line) {
|
|
21
|
+
const home = line.split(":")[5];
|
|
22
|
+
if (home) return home;
|
|
23
|
+
}
|
|
24
|
+
} catch (_) { /* fall through */ }
|
|
25
|
+
}
|
|
26
|
+
return os.homedir();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { userHome };
|
|
@@ -1,426 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* `deepsql index-recommendations` — surface DeepSQL's DBA-grade index
|
|
5
|
-
* advisor from the terminal. Mirrors the MCP tools (`get_index_recommendations`
|
|
6
|
-
* + `apply_index_recommendation`) so an on-call DBA gets the same workflow
|
|
7
|
-
* an AI coding agent does, without needing the IDE plumbing.
|
|
8
|
-
*
|
|
9
|
-
* Subcommands:
|
|
10
|
-
* deepsql index-recommendations top --connection <name> [--limit N] [--json]
|
|
11
|
-
* deepsql index-recommendations list --connection <name> [--json]
|
|
12
|
-
* deepsql index-recommendations show <id> [--json]
|
|
13
|
-
* deepsql index-recommendations refresh --connection <name> [--json]
|
|
14
|
-
* deepsql index-recommendations apply <id> [--mode dry-run|apply|apply-and-measure]
|
|
15
|
-
* [--confirm] [--concurrent|--no-concurrent]
|
|
16
|
-
* [--json]
|
|
17
|
-
* deepsql index-recommendations dismiss <id> [--json]
|
|
18
|
-
*
|
|
19
|
-
* Default output is a compact terminal-friendly table. `--json` passes the
|
|
20
|
-
* raw backend payload through, which is what callers from scripts / CI
|
|
21
|
-
* pipelines want.
|
|
22
|
-
*
|
|
23
|
-
* Apply is the one mutation. The contract matches the MCP tool: default
|
|
24
|
-
* mode is `dry-run` (HypoPG-based, no writes); the two write modes
|
|
25
|
-
* (`apply`, `apply-and-measure`) require `--confirm`. `--no-concurrent`
|
|
26
|
-
* opts out of CREATE INDEX CONCURRENTLY when the brief ACCESS EXCLUSIVE
|
|
27
|
-
* lock is acceptable (small dev tables, or after the DBA has drained the
|
|
28
|
-
* connection pool).
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
const { ApiError, request } = require("../api/client");
|
|
32
|
-
const { resolveSession } = require("./_session");
|
|
33
|
-
const { resolveConnectionId } = require("./_connections");
|
|
34
|
-
|
|
35
|
-
const SUBCOMMANDS = {
|
|
36
|
-
top: cmdTop,
|
|
37
|
-
list: cmdList,
|
|
38
|
-
show: cmdShow,
|
|
39
|
-
refresh: cmdRefresh,
|
|
40
|
-
apply: cmdApply,
|
|
41
|
-
dismiss: cmdDismiss,
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
async function run(opts, io = {}) {
|
|
45
|
-
const sub = opts.positional[0];
|
|
46
|
-
if (!sub) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
"Usage: deepsql index-recommendations <top|list|show|refresh|apply|dismiss> …"
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
const handler = SUBCOMMANDS[sub];
|
|
52
|
-
if (!handler) {
|
|
53
|
-
throw new Error(`Unknown index-recommendations subcommand: ${sub}.`);
|
|
54
|
-
}
|
|
55
|
-
return wrap(handler)(
|
|
56
|
-
{ ...opts, positional: opts.positional.slice(1) },
|
|
57
|
-
io
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function wrap(handler) {
|
|
62
|
-
return async (opts, io) => {
|
|
63
|
-
try {
|
|
64
|
-
return await handler(opts, io);
|
|
65
|
-
} catch (err) {
|
|
66
|
-
if (err instanceof ApiError && err.status === 403) {
|
|
67
|
-
throw new Error(
|
|
68
|
-
"Access denied — index-recommendation operations require permissions on this connection."
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
if (err instanceof ApiError && err.status === 404) {
|
|
72
|
-
throw new Error(err.message || "Recommendation not found.");
|
|
73
|
-
}
|
|
74
|
-
throw err;
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ─── top ───────────────────────────────────────────────────────────────────
|
|
80
|
-
|
|
81
|
-
async function cmdTop(opts, { stdout = process.stdout } = {}) {
|
|
82
|
-
const session = resolveSession(opts);
|
|
83
|
-
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
84
|
-
const limit = clampLimit(opts.limit, 5);
|
|
85
|
-
const result = await request(
|
|
86
|
-
session.baseUrl,
|
|
87
|
-
`/index-recommendations/${encodeURIComponent(connectionId)}/top`,
|
|
88
|
-
{ token: session.token, query: { limit } }
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
if (opts.json) {
|
|
92
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const list = Array.isArray(result) ? result : [];
|
|
97
|
-
if (list.length === 0) {
|
|
98
|
-
stdout.write(
|
|
99
|
-
"No pending index recommendations. The 6-hour refresh scheduler may not have run yet, " +
|
|
100
|
-
"or the workload has none worth flagging. Try `deepsql index-recommendations refresh` " +
|
|
101
|
-
"to force a fresh accumulation cycle.\n"
|
|
102
|
-
);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
stdout.write(`Top ${list.length} pending index recommendation(s):\n\n`);
|
|
107
|
-
list.forEach((r, idx) => {
|
|
108
|
-
const action = r.kind === "DROP_INDEX" ? "DROP" : "CREATE";
|
|
109
|
-
const target =
|
|
110
|
-
r.kind === "DROP_INDEX"
|
|
111
|
-
? `${r.tableName}.${r.indexName} (unused)`
|
|
112
|
-
: `${r.tableName}(${r.columnNames})`;
|
|
113
|
-
|
|
114
|
-
const meta = [
|
|
115
|
-
r.priority,
|
|
116
|
-
r.occurrenceCount != null ? `seen ${r.occurrenceCount}×` : null,
|
|
117
|
-
formatNet(r),
|
|
118
|
-
r.evidenceCount > 0 ? `${r.evidenceCount} ev` : null,
|
|
119
|
-
]
|
|
120
|
-
.filter(Boolean)
|
|
121
|
-
.join(", ");
|
|
122
|
-
|
|
123
|
-
stdout.write(`${idx + 1}. [${action}] ${target} — ${meta}\n`);
|
|
124
|
-
stdout.write(` id: ${r.id}\n`);
|
|
125
|
-
if (r.hypopgReductionPct != null) {
|
|
126
|
-
stdout.write(
|
|
127
|
-
` HypoPG cost: ${formatCost(r.hypopgBeforeCost)} → ${formatCost(r.hypopgAfterCost)} ` +
|
|
128
|
-
`(${signedPct(r.hypopgReductionPct)})\n`
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
if (r.reason) {
|
|
132
|
-
stdout.write(` ${truncate(r.reason, 200)}\n`);
|
|
133
|
-
}
|
|
134
|
-
if (Array.isArray(r.topEvidence) && r.topEvidence.length) {
|
|
135
|
-
const ev = r.topEvidence[0];
|
|
136
|
-
stdout.write(
|
|
137
|
-
` top evidence: ${ev.calls} calls × ${formatMs(ev.meanExecTimeMs)}` +
|
|
138
|
-
` mean = ${formatMs(ev.totalExecTimeMs)} total (${ev.role})\n`
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
stdout.write("\n");
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ─── list / show ───────────────────────────────────────────────────────────
|
|
146
|
-
|
|
147
|
-
async function cmdList(opts, { stdout = process.stdout } = {}) {
|
|
148
|
-
const session = resolveSession(opts);
|
|
149
|
-
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
150
|
-
const result = await request(
|
|
151
|
-
session.baseUrl,
|
|
152
|
-
`/index-recommendations/pending/${encodeURIComponent(connectionId)}`,
|
|
153
|
-
{ token: session.token }
|
|
154
|
-
);
|
|
155
|
-
if (opts.json) {
|
|
156
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
const list = Array.isArray(result) ? result : [];
|
|
160
|
-
if (list.length === 0) {
|
|
161
|
-
stdout.write("No pending index recommendations.\n");
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
stdout.write(`${list.length} pending recommendation(s):\n\n`);
|
|
165
|
-
list.forEach((r, idx) => {
|
|
166
|
-
const action = r.kind === "DROP_INDEX" ? "DROP" : "CREATE";
|
|
167
|
-
stdout.write(
|
|
168
|
-
`${idx + 1}. [${action}] ${r.tableName}(${r.columnNames || r.indexName}) — ${r.priority}, occ=${r.occurrenceCount}, id=${r.id}\n`
|
|
169
|
-
);
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function cmdShow(opts, { stdout = process.stdout } = {}) {
|
|
174
|
-
const id = opts.positional[0];
|
|
175
|
-
if (!id) throw new Error("Usage: deepsql index-recommendations show <id>");
|
|
176
|
-
const session = resolveSession(opts);
|
|
177
|
-
// No direct GET by id endpoint — use the connection's top with a wide
|
|
178
|
-
// limit and filter client-side. Cheap enough for inspection use.
|
|
179
|
-
const connectionId = opts.connection
|
|
180
|
-
? await resolveConnectionId(session, opts.connection)
|
|
181
|
-
: null;
|
|
182
|
-
if (!connectionId) {
|
|
183
|
-
throw new Error(
|
|
184
|
-
"`show` needs --connection <name> to look up the recommendation (or pin one with `deepsql connections use`)."
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
const list = await request(
|
|
188
|
-
session.baseUrl,
|
|
189
|
-
`/index-recommendations/${encodeURIComponent(connectionId)}/top`,
|
|
190
|
-
{ token: session.token, query: { limit: 50 } }
|
|
191
|
-
);
|
|
192
|
-
const found = (list || []).find((r) => r.id === id);
|
|
193
|
-
if (!found) {
|
|
194
|
-
throw new Error(`Recommendation ${id} not in the top-50 for this connection.`);
|
|
195
|
-
}
|
|
196
|
-
if (opts.json) {
|
|
197
|
-
stdout.write(`${JSON.stringify(found, null, 2)}\n`);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
renderRecommendationDetail(stdout, found);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ─── refresh ───────────────────────────────────────────────────────────────
|
|
204
|
-
|
|
205
|
-
async function cmdRefresh(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
206
|
-
const session = resolveSession(opts);
|
|
207
|
-
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
208
|
-
stderr.write(`Refreshing index recommendations on ${opts.connection || connectionId}…\n`);
|
|
209
|
-
const result = await request(
|
|
210
|
-
session.baseUrl,
|
|
211
|
-
`/index-recommendations/generate/${encodeURIComponent(connectionId)}`,
|
|
212
|
-
{ method: "POST", token: session.token, timeoutMs: 600000 }
|
|
213
|
-
);
|
|
214
|
-
if (opts.json) {
|
|
215
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
if (result && result.success) {
|
|
219
|
-
stdout.write(
|
|
220
|
-
`Refresh complete: ${result.count} candidate(s) merged. ` +
|
|
221
|
-
`Use \`deepsql index-recommendations top\` to see the top-N.\n`
|
|
222
|
-
);
|
|
223
|
-
} else {
|
|
224
|
-
stdout.write(`Refresh response: ${JSON.stringify(result)}\n`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ─── apply ─────────────────────────────────────────────────────────────────
|
|
229
|
-
|
|
230
|
-
async function cmdApply(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
231
|
-
const id = opts.positional[0];
|
|
232
|
-
if (!id) {
|
|
233
|
-
throw new Error(
|
|
234
|
-
"Usage: deepsql index-recommendations apply <id> [--mode dry-run|apply|apply-and-measure] [--confirm] [--no-concurrent]"
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const mode = normalizeMode(opts.mode);
|
|
239
|
-
const isWriteMode = mode === "APPLY" || mode === "APPLY_AND_MEASURE";
|
|
240
|
-
if (isWriteMode && !opts.confirm) {
|
|
241
|
-
throw new Error(
|
|
242
|
-
`Mode ${mode} mutates the target database. Re-run with --confirm to proceed.`
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
// --concurrent defaults to true; --no-concurrent flips it off (parser
|
|
246
|
-
// surfaces noConcurrent boolean).
|
|
247
|
-
const concurrent = !opts.noConcurrent;
|
|
248
|
-
|
|
249
|
-
const session = resolveSession(opts);
|
|
250
|
-
if (isWriteMode) {
|
|
251
|
-
stderr.write(`Running ${mode} on recommendation ${id}…\n`);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const result = await request(
|
|
255
|
-
session.baseUrl,
|
|
256
|
-
`/index-recommendations/${encodeURIComponent(id)}/apply`,
|
|
257
|
-
{
|
|
258
|
-
method: "POST",
|
|
259
|
-
token: session.token,
|
|
260
|
-
timeoutMs: 600000,
|
|
261
|
-
query: { mode, confirm: opts.confirm === true, concurrent },
|
|
262
|
-
}
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
if (opts.json) {
|
|
266
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
renderApplyResult(stdout, result);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// ─── dismiss ───────────────────────────────────────────────────────────────
|
|
273
|
-
|
|
274
|
-
async function cmdDismiss(opts, { stdout = process.stdout } = {}) {
|
|
275
|
-
const id = opts.positional[0];
|
|
276
|
-
if (!id) throw new Error("Usage: deepsql index-recommendations dismiss <id>");
|
|
277
|
-
const session = resolveSession(opts);
|
|
278
|
-
const result = await request(
|
|
279
|
-
session.baseUrl,
|
|
280
|
-
`/index-recommendations/${encodeURIComponent(id)}/dismiss`,
|
|
281
|
-
{ method: "PUT", token: session.token }
|
|
282
|
-
);
|
|
283
|
-
if (opts.json) {
|
|
284
|
-
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
stdout.write(`Dismissed recommendation ${id}.\n`);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// ─── formatting helpers ────────────────────────────────────────────────────
|
|
291
|
-
|
|
292
|
-
function renderRecommendationDetail(stdout, r) {
|
|
293
|
-
const action = r.kind === "DROP_INDEX" ? "DROP" : "CREATE";
|
|
294
|
-
stdout.write(`[${action}] ${r.tableName}(${r.columnNames || r.indexName})\n`);
|
|
295
|
-
stdout.write(` id : ${r.id}\n`);
|
|
296
|
-
stdout.write(` priority : ${r.priority}\n`);
|
|
297
|
-
stdout.write(` status : ${r.status || "PENDING"}\n`);
|
|
298
|
-
stdout.write(` occurrenceCnt : ${r.occurrenceCount}\n`);
|
|
299
|
-
stdout.write(` workloadScore : ${formatMs(r.workloadScoreMs)}\n`);
|
|
300
|
-
stdout.write(` writeCost : ${formatMs(r.writeCostScore)}\n`);
|
|
301
|
-
stdout.write(` netBenefit : ${formatMs(r.netBenefitMs)}\n`);
|
|
302
|
-
if (r.hypopgReductionPct != null) {
|
|
303
|
-
stdout.write(
|
|
304
|
-
` HypoPG : ${formatCost(r.hypopgBeforeCost)} → ${formatCost(r.hypopgAfterCost)} ` +
|
|
305
|
-
`(${signedPct(r.hypopgReductionPct)})\n`
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
stdout.write(` DDL : ${r.createStatement}\n`);
|
|
309
|
-
if (r.reason) stdout.write(` reason : ${r.reason}\n`);
|
|
310
|
-
if (Array.isArray(r.topEvidence) && r.topEvidence.length) {
|
|
311
|
-
stdout.write(` contributing queries (${r.topEvidence.length}):\n`);
|
|
312
|
-
for (const ev of r.topEvidence) {
|
|
313
|
-
stdout.write(
|
|
314
|
-
` - fp=${(ev.fingerprint || "?").slice(0, 12)} calls=${ev.calls} mean=${formatMs(ev.meanExecTimeMs)} total=${formatMs(ev.totalExecTimeMs)} role=${ev.role}\n`
|
|
315
|
-
);
|
|
316
|
-
if (ev.exampleSql) {
|
|
317
|
-
stdout.write(` ${truncate(ev.exampleSql, 200)}\n`);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function renderApplyResult(stdout, r) {
|
|
324
|
-
if (!r || typeof r !== "object") {
|
|
325
|
-
stdout.write("Apply returned no body.\n");
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
const status = r.status || "?";
|
|
329
|
-
if (status === "BLOCKED_NEEDS_CONFIRMATION") {
|
|
330
|
-
stdout.write(`[${r.mode}] blocked — pass --confirm to mutate the database.\n`);
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
if (status === "NOT_FOUND") {
|
|
334
|
-
stdout.write(`[${r.mode}] recommendation not found: ${r.recommendationId}\n`);
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
if (status === "NO_USABLE_SAMPLES") {
|
|
338
|
-
stdout.write(
|
|
339
|
-
`[${r.mode}] no literal-bearing contributing queries available; cannot measure.\n`
|
|
340
|
-
);
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
if (status === "FAILED") {
|
|
344
|
-
stdout.write(`[${r.mode}] failed: ${r.message || "(no message)"}\n`);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
stdout.write(`[${r.mode}] ${status}\n`);
|
|
349
|
-
stdout.write(` DDL: ${r.executedDdl}\n`);
|
|
350
|
-
if (r.beforeCost != null && r.afterCost != null) {
|
|
351
|
-
stdout.write(
|
|
352
|
-
` planner cost: ${formatCost(r.beforeCost)} → ${formatCost(r.afterCost)} ` +
|
|
353
|
-
`(${signedPct(r.costReductionPct)})\n`
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
if (r.beforeWallTimeMs != null && r.afterWallTimeMs != null) {
|
|
357
|
-
stdout.write(
|
|
358
|
-
` wall time: ${formatMs(r.beforeWallTimeMs)} → ${formatMs(r.afterWallTimeMs)} ` +
|
|
359
|
-
`(${signedPct(r.wallTimeImprovementPct)})\n`
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
if (Array.isArray(r.samples) && r.samples.length) {
|
|
363
|
-
stdout.write(` ${r.samples.length} contributing query sample(s):\n`);
|
|
364
|
-
for (const s of r.samples.slice(0, 5)) {
|
|
365
|
-
stdout.write(
|
|
366
|
-
` fp=${(s.fingerprint || "?").slice(0, 12)} cost ${formatCost(s.beforeCost)} → ${formatCost(s.afterCost)}` +
|
|
367
|
-
(s.error ? ` (error: ${s.error})` : "") +
|
|
368
|
-
"\n"
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
if (r.message) stdout.write(` ${r.message}\n`);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function clampLimit(raw, fallback) {
|
|
376
|
-
const n = Number.parseInt(raw, 10);
|
|
377
|
-
if (!Number.isFinite(n)) return fallback;
|
|
378
|
-
return Math.min(50, Math.max(1, n));
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function normalizeMode(raw) {
|
|
382
|
-
if (!raw) return "DRY_RUN";
|
|
383
|
-
// Accept dash-form (CLI convention) and underscore-form (API enum).
|
|
384
|
-
const m = String(raw).toUpperCase().replace(/-/g, "_");
|
|
385
|
-
if (!["DRY_RUN", "APPLY", "APPLY_AND_MEASURE"].includes(m)) {
|
|
386
|
-
throw new Error(`Unknown mode: ${raw}. Expected dry-run, apply, or apply-and-measure.`);
|
|
387
|
-
}
|
|
388
|
-
return m;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function formatMs(ms) {
|
|
392
|
-
if (ms == null || !Number.isFinite(Number(ms))) return "—";
|
|
393
|
-
const n = Number(ms);
|
|
394
|
-
if (n <= 0) return "0ms";
|
|
395
|
-
if (n >= 86_400_000) return `${(n / 86_400_000).toFixed(1)}d`;
|
|
396
|
-
if (n >= 3_600_000) return `${(n / 3_600_000).toFixed(1)}h`;
|
|
397
|
-
if (n >= 60_000) return `${(n / 60_000).toFixed(1)}m`;
|
|
398
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}s`;
|
|
399
|
-
return `${n.toFixed(n >= 10 ? 0 : 1)}ms`;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function formatCost(c) {
|
|
403
|
-
if (c == null || !Number.isFinite(Number(c))) return "—";
|
|
404
|
-
return Number(c).toFixed(0);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function signedPct(pct) {
|
|
408
|
-
if (pct == null || !Number.isFinite(Number(pct))) return "—";
|
|
409
|
-
const sign = pct >= 0 ? "−" : "+";
|
|
410
|
-
return `${sign}${Math.abs(pct).toFixed(1)}%`;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function formatNet(r) {
|
|
414
|
-
if (r.netBenefitMs != null && r.netBenefitMs > 0) {
|
|
415
|
-
return `net=${formatMs(r.netBenefitMs)} saved`;
|
|
416
|
-
}
|
|
417
|
-
if (r.estimatedImpact != null) return `impact ${r.estimatedImpact}`;
|
|
418
|
-
return null;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function truncate(s, max) {
|
|
422
|
-
if (!s) return s;
|
|
423
|
-
return s.length <= max ? s : `${s.slice(0, max)}…`;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
module.exports = { run };
|