@deepsql/mcp 0.11.0 → 0.13.4
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/AGENT-SETUP.md +83 -31
- package/CLAUDE.md +382 -0
- package/README.md +109 -25
- package/claude_desktop_config.customer.example.json +3 -11
- package/codex_config.customer.example.toml +12 -8
- package/deepsql-phase1-lib.js +149 -27
- package/deepsql-phase1-server.js +1 -1
- package/package.json +3 -2
- package/skills/SKILL_BODY.md +125 -0
- package/src/api/client.js +35 -1
- package/src/cli.js +65 -20
- package/src/cli.test.js +1 -1
- package/src/commands/analyze.js +165 -0
- package/src/commands/analyze.test.js +180 -0
- package/src/commands/explain.js +18 -34
- package/src/commands/mcp.js +592 -8
- package/src/commands/mcp.test.js +529 -0
- package/src/commands/query.js +95 -13
- package/src/commands/query.test.js +214 -0
package/src/api/client.js
CHANGED
|
@@ -19,6 +19,27 @@ class ApiError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Module-level "who is talking to the backend" record. The CLI's entry
|
|
24
|
+
* point calls setClientContext() once at startup, then every request()
|
|
25
|
+
* call below stamps X-DeepSQL-Client-{Type,Agent,Version} headers without
|
|
26
|
+
* each command needing to thread that information through.
|
|
27
|
+
*
|
|
28
|
+
* type "cli" by default for everything that goes through this module
|
|
29
|
+
* agent "terminal", or the value of --caller-agent / DEEPSQL_CALLER_AGENT
|
|
30
|
+
* (set by agents like claude-code when they shell out to `deepsql`)
|
|
31
|
+
* version the npm package version, so backend audit shows which CLI build
|
|
32
|
+
*/
|
|
33
|
+
let currentClient = null;
|
|
34
|
+
|
|
35
|
+
function setClientContext(client) {
|
|
36
|
+
currentClient = client ? { ...client } : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getClientContext() {
|
|
40
|
+
return currentClient;
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
function normalizeBaseUrl(url) {
|
|
23
44
|
if (!url) throw new ApiError("No DeepSQL URL configured. Run `deepsql login --url <url>` first.");
|
|
24
45
|
return url.endsWith("/") ? url : `${url}/`;
|
|
@@ -54,6 +75,11 @@ async function request(baseUrl, pathOrUrl, { method = "GET", json, headers, toke
|
|
|
54
75
|
};
|
|
55
76
|
if (token) requestHeaders.Authorization = `Bearer ${token}`;
|
|
56
77
|
if (json != null) requestHeaders["Content-Type"] = "application/json";
|
|
78
|
+
if (currentClient) {
|
|
79
|
+
if (currentClient.type) requestHeaders["X-DeepSQL-Client-Type"] = currentClient.type;
|
|
80
|
+
if (currentClient.agent) requestHeaders["X-DeepSQL-Client-Agent"] = currentClient.agent;
|
|
81
|
+
if (currentClient.version) requestHeaders["X-DeepSQL-Client-Version"] = currentClient.version;
|
|
82
|
+
}
|
|
57
83
|
|
|
58
84
|
const controller = new AbortController();
|
|
59
85
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -114,4 +140,12 @@ function parseCookieValue(setCookies, name) {
|
|
|
114
140
|
return null;
|
|
115
141
|
}
|
|
116
142
|
|
|
117
|
-
module.exports = {
|
|
143
|
+
module.exports = {
|
|
144
|
+
ApiError,
|
|
145
|
+
request,
|
|
146
|
+
resolveUrl,
|
|
147
|
+
normalizeBaseUrl,
|
|
148
|
+
parseCookieValue,
|
|
149
|
+
setClientContext,
|
|
150
|
+
getClientContext,
|
|
151
|
+
};
|
package/src/cli.js
CHANGED
|
@@ -21,7 +21,8 @@ const COMMANDS = {
|
|
|
21
21
|
mcp: () => require("./commands/mcp"),
|
|
22
22
|
connections: () => require("./commands/connections"),
|
|
23
23
|
query: () => require("./commands/query"),
|
|
24
|
-
|
|
24
|
+
analyze: () => require("./commands/analyze"),
|
|
25
|
+
explain: () => require("./commands/explain"), // deprecated alias for `analyze`, removed in 0.14.0
|
|
25
26
|
schema: () => require("./commands/schema"),
|
|
26
27
|
// Brain tools — give a coding agent the same retrieval context the chat
|
|
27
28
|
// pipeline uses, then let the agent generate SQL/answers itself.
|
|
@@ -48,10 +49,10 @@ const COMMAND_LIST = [
|
|
|
48
49
|
["logout", false, "Revoke and forget the saved token"],
|
|
49
50
|
["whoami", false, "Show the user behind the saved token"],
|
|
50
51
|
["config", true, "Manage saved CLI profiles"],
|
|
51
|
-
["mcp",
|
|
52
|
+
["mcp", true, "Run the MCP server or install it into an editor config"],
|
|
52
53
|
["connections", true, "Manage database connections"],
|
|
53
|
-
["query", false, "
|
|
54
|
-
["
|
|
54
|
+
["query", false, "Execute a SQL statement (admin: DDL/DML with --write)"],
|
|
55
|
+
["analyze", false, "AI-enriched query plan analysis (use --analyze for EXPLAIN ANALYZE)"],
|
|
55
56
|
["schema", false, "Dump connection schema or DB objects as JSON"],
|
|
56
57
|
["digest", true, "Show DeepSQL daily digests"],
|
|
57
58
|
["brain-context", false, "Retrieve embedding-ranked context for a question"],
|
|
@@ -67,12 +68,13 @@ const COMMAND_LIST = [
|
|
|
67
68
|
];
|
|
68
69
|
|
|
69
70
|
const GLOBAL_OPTIONS = [
|
|
70
|
-
["--url <url>",
|
|
71
|
-
["--token <tok>",
|
|
72
|
-
["--connection <name>",
|
|
73
|
-
["--
|
|
74
|
-
["-
|
|
75
|
-
["-
|
|
71
|
+
["--url <url>", "Override the DeepSQL base URL"],
|
|
72
|
+
["--token <tok>", "Override the auth token (also: DEEPSQL_AUTH_TOKEN)"],
|
|
73
|
+
["--connection <name>", "Override the active connection (also: DEEPSQL_CONNECTION)"],
|
|
74
|
+
["--caller-agent <id>", "Identify the calling agent in audit logs (also: DEEPSQL_CALLER_AGENT)"],
|
|
75
|
+
["--no-color", "Disable ANSI colors"],
|
|
76
|
+
["-h, --help", "Display help for command"],
|
|
77
|
+
["-v, --version", "Show version"],
|
|
76
78
|
];
|
|
77
79
|
|
|
78
80
|
// ─── Per-command help blocks ────────────────────────────────────────────────
|
|
@@ -112,9 +114,23 @@ const COMMAND_HELP = {
|
|
|
112
114
|
},
|
|
113
115
|
|
|
114
116
|
mcp: {
|
|
115
|
-
description: "Run the stdio MCP server
|
|
116
|
-
usage: "deepsql mcp [--url <url>]",
|
|
117
|
-
|
|
117
|
+
description: "Run the stdio MCP server, or install DeepSQL into an editor's MCP config (+ the DBA-consult skill).",
|
|
118
|
+
usage: "deepsql mcp [--url <url>]\n deepsql mcp config (--install | --print) --for <editor> [--force] [--no-skill] [--path <p>]",
|
|
119
|
+
subcommands: [
|
|
120
|
+
["(no args)", "Run the stdio MCP server with the saved token"],
|
|
121
|
+
["config --install --for <editor>", "Write a DeepSQL entry into the editor's MCP config AND install the DBA-consult skill (backs up on overwrite)"],
|
|
122
|
+
["config --print --for <editor>", "Print the snippets (config + skill) without touching disk"],
|
|
123
|
+
],
|
|
124
|
+
options: [
|
|
125
|
+
["--for <editor>", "claude-code, claude-desktop, cursor, or codex"],
|
|
126
|
+
["--install", "Write the entry to the editor's default config path AND install the skill"],
|
|
127
|
+
["--print", "Emit the snippets only (no filesystem writes)"],
|
|
128
|
+
["--force", "Overwrite an existing DeepSQL entry/skill"],
|
|
129
|
+
["--no-skill", "Skip the DBA-consult skill install (MCP server config only)"],
|
|
130
|
+
["--path <p>", "Override the default MCP config path (advanced; doesn't affect the skill path)"],
|
|
131
|
+
["--url <url>", "Profile to bind the spawned MCP server to"],
|
|
132
|
+
],
|
|
133
|
+
notes: "The installed entry runs `deepsql mcp`, which uses the saved profile — no token is embedded in the editor config. The skill teaches the agent to consult DeepSQL BEFORE generating DDL or non-trivial SQL.",
|
|
118
134
|
},
|
|
119
135
|
|
|
120
136
|
config: {
|
|
@@ -158,25 +174,30 @@ const COMMAND_HELP = {
|
|
|
158
174
|
},
|
|
159
175
|
|
|
160
176
|
query: {
|
|
161
|
-
description: "
|
|
177
|
+
description: "Execute a SQL statement against a connection. Same policy gate as the web SQL Editor: developers can run SELECT/WITH/SHOW/EXPLAIN; admins can additionally run DML/DDL with a two-step confirm.",
|
|
162
178
|
usage: 'deepsql query "<sql>" --connection <name> [options]',
|
|
163
179
|
options: [
|
|
164
180
|
["--connection <name>", "Connection to run against"],
|
|
165
181
|
["--limit <n>", "Row limit (1–1000, default 100)"],
|
|
166
182
|
["--timeout-seconds <n>", "Statement timeout in seconds (1–60)"],
|
|
167
183
|
["--file <path>", "Read SQL from a file instead of argv"],
|
|
184
|
+
["--write", "Confirm a mutation upfront (skips interactive prompt; scripts/CI)"],
|
|
168
185
|
["--json", "Raw JSON output"],
|
|
169
186
|
],
|
|
187
|
+
notes: "EXPLAIN and EXPLAIN ANALYZE are valid SQL — type them directly. For the AI-enriched plan analysis, use `deepsql analyze`.",
|
|
170
188
|
},
|
|
171
189
|
|
|
172
|
-
|
|
173
|
-
description: "
|
|
174
|
-
usage: 'deepsql
|
|
190
|
+
analyze: {
|
|
191
|
+
description: "AI-enriched query plan analysis. Returns the parsed plan tree, performance issues, index recommendations, and a written summary that takes the connection's schema and business rules into account.",
|
|
192
|
+
usage: 'deepsql analyze "<sql>" --connection <name> [options]',
|
|
175
193
|
options: [
|
|
176
|
-
["--connection <name>",
|
|
177
|
-
["--
|
|
178
|
-
["--
|
|
194
|
+
["--connection <name>", "Connection to run against"],
|
|
195
|
+
["--analyze", "Use EXPLAIN ANALYZE (actually executes the query)"],
|
|
196
|
+
["--write", "Confirm an ANALYZE-of-mutation upfront (skips interactive prompt)"],
|
|
197
|
+
["--file <path>", "Read SQL from a file instead of argv"],
|
|
198
|
+
["--json", "Raw JSON output"],
|
|
179
199
|
],
|
|
200
|
+
notes: "Pass the underlying SQL, not `EXPLAIN <sql>` — the server wraps it. With --analyze on a mutation, the same admin role + WHERE + confirmation gates as `query` apply.",
|
|
180
201
|
},
|
|
181
202
|
|
|
182
203
|
schema: {
|
|
@@ -525,6 +546,17 @@ function buildOpts(parsed) {
|
|
|
525
546
|
// Indexes
|
|
526
547
|
all: !!f.all,
|
|
527
548
|
status: f.status || null,
|
|
549
|
+
// mcp config installer
|
|
550
|
+
install: !!f.install,
|
|
551
|
+
print: !!f.print,
|
|
552
|
+
for: f.for || null,
|
|
553
|
+
path: f.path || null,
|
|
554
|
+
noSkill: !!f.noSkill,
|
|
555
|
+
// SQL execution
|
|
556
|
+
write: !!f.write,
|
|
557
|
+
analyze: !!f.analyze,
|
|
558
|
+
// Origin tagging for audit
|
|
559
|
+
callerAgent: f.callerAgent || null,
|
|
528
560
|
// Setup wizard
|
|
529
561
|
force: !!f.force,
|
|
530
562
|
skipEmail: !!f.skipEmail,
|
|
@@ -582,6 +614,19 @@ async function main(rawArgv = process.argv.slice(2), io = {}) {
|
|
|
582
614
|
const parsed = parseArgs(restArgs);
|
|
583
615
|
const opts = buildOpts(parsed);
|
|
584
616
|
|
|
617
|
+
// Stamp origin headers on every backend request the command will make.
|
|
618
|
+
// `clientType` is fixed for CLI traffic; `clientAgent` defaults to
|
|
619
|
+
// "terminal" but agents that shell out can override via --caller-agent or
|
|
620
|
+
// the DEEPSQL_CALLER_AGENT env var, so audit rows distinguish "human in a
|
|
621
|
+
// terminal" from "Claude Code invoked deepsql query."
|
|
622
|
+
const { setClientContext } = require("./api/client");
|
|
623
|
+
const pkg = require("../package.json");
|
|
624
|
+
setClientContext({
|
|
625
|
+
type: "cli",
|
|
626
|
+
agent: opts.callerAgent || process.env.DEEPSQL_CALLER_AGENT || "terminal",
|
|
627
|
+
version: pkg.version,
|
|
628
|
+
});
|
|
629
|
+
|
|
585
630
|
try {
|
|
586
631
|
const mod = loader();
|
|
587
632
|
await mod.run(opts, { stdout, stderr });
|
package/src/cli.test.js
CHANGED
|
@@ -73,7 +73,7 @@ test("every command in the catalog has a COMMAND_HELP entry", () => {
|
|
|
73
73
|
const { main: _main } = require("./cli");
|
|
74
74
|
void _main;
|
|
75
75
|
const expected = [
|
|
76
|
-
"login","logout","whoami","config","mcp","connections","query","
|
|
76
|
+
"login","logout","whoami","config","mcp","connections","query","analyze","schema",
|
|
77
77
|
"digest","brain-context","business-rules","relationships","anti-patterns","indexes",
|
|
78
78
|
"users","access","permissions","slow-queries","setup",
|
|
79
79
|
];
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql analyze` — AI-enriched query plan analysis.
|
|
5
|
+
*
|
|
6
|
+
* Hits the canonical Editor endpoint `/api/explain/analyze`. The response is
|
|
7
|
+
* an ExplainPlanAnalysis carrying:
|
|
8
|
+
* - the parsed plan tree (planTree), raw plan text/JSON
|
|
9
|
+
* - performance issues (slow nodes, missing index hints, bad estimates)
|
|
10
|
+
* - index recommendations
|
|
11
|
+
* - an LLM-written summary that takes the connection's schema, business
|
|
12
|
+
* rules, and detected anti-patterns into account
|
|
13
|
+
*
|
|
14
|
+
* Two modes:
|
|
15
|
+
* - default (`useAnalyze=false`) Plain EXPLAIN, no execution. Safe for any
|
|
16
|
+
* actor with read access to the connection.
|
|
17
|
+
* - `--analyze` (`useAnalyze=true`) EXPLAIN ANALYZE — actually runs the
|
|
18
|
+
* query. For mutations this requires the
|
|
19
|
+
* same admin role + WHERE + confirmation
|
|
20
|
+
* gate as `deepsql query`.
|
|
21
|
+
*
|
|
22
|
+
* Pass `--write` to confirm an EXPLAIN ANALYZE of a mutation upfront (skips
|
|
23
|
+
* the interactive prompt).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require("node:fs");
|
|
27
|
+
const { request } = require("../api/client");
|
|
28
|
+
const { resolveSession } = require("./_session");
|
|
29
|
+
const { resolveConnectionId } = require("./_connections");
|
|
30
|
+
const ui = require("../ui/prompts");
|
|
31
|
+
|
|
32
|
+
async function run(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
33
|
+
const sql = readSqlInput(opts);
|
|
34
|
+
const session = resolveSession(opts);
|
|
35
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
36
|
+
|
|
37
|
+
const body = {
|
|
38
|
+
connectionId,
|
|
39
|
+
query: sql,
|
|
40
|
+
useAnalyze: !!opts.analyze,
|
|
41
|
+
mutationConfirmed: !!opts.write,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
let response;
|
|
45
|
+
try {
|
|
46
|
+
response = await runOnce(session, body);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// Two-step mutation: the policy gate throws when useAnalyze=true with
|
|
49
|
+
// a mutation and no confirm. The server packs requiresConfirmation
|
|
50
|
+
// into the error response body.
|
|
51
|
+
if (err.body && err.body.requiresConfirmation && !opts.write) {
|
|
52
|
+
const accepted = await promptForConfirmation(err.body, { stderr });
|
|
53
|
+
if (!accepted) {
|
|
54
|
+
stderr.write("Aborted.\n");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
response = await runOnce(session, { ...body, mutationConfirmed: true });
|
|
58
|
+
} else {
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (opts.json) {
|
|
64
|
+
stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
renderAnalysis(stdout, response);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function runOnce(session, body) {
|
|
71
|
+
return request(session.baseUrl, "/explain/analyze", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
token: session.token,
|
|
74
|
+
json: body,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function promptForConfirmation(errorBody, { stderr = process.stderr }) {
|
|
79
|
+
stderr.write(
|
|
80
|
+
`\n⚠ ${errorBody.message || "EXPLAIN ANALYZE will actually execute this statement."}\n`,
|
|
81
|
+
);
|
|
82
|
+
if (errorBody.queryType) stderr.write(` statement: ${errorBody.queryType}\n`);
|
|
83
|
+
const warnings = Array.isArray(errorBody.warnings) ? errorBody.warnings : [];
|
|
84
|
+
for (const w of warnings) {
|
|
85
|
+
stderr.write(` • ${w}\n`);
|
|
86
|
+
}
|
|
87
|
+
stderr.write("\n");
|
|
88
|
+
return ui.confirm({ message: "Run EXPLAIN ANALYZE on the statement?", default: false });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderAnalysis(stdout, response) {
|
|
92
|
+
if (!response || typeof response !== "object") {
|
|
93
|
+
stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Plain EXPLAIN responses set wasExecuted=false; EXPLAIN ANALYZE sets it
|
|
98
|
+
// to true. Surface the distinction at the top.
|
|
99
|
+
const heading = response.wasExecuted
|
|
100
|
+
? "Plan (executed via EXPLAIN ANALYZE):"
|
|
101
|
+
: "Plan (EXPLAIN, not executed):";
|
|
102
|
+
stdout.write(`${heading}\n`);
|
|
103
|
+
|
|
104
|
+
if (response.planText) {
|
|
105
|
+
stdout.write(`${String(response.planText).trim()}\n\n`);
|
|
106
|
+
} else if (response.planJson) {
|
|
107
|
+
stdout.write(`${response.planJson}\n\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const numbers = [];
|
|
111
|
+
if (response.totalTimeMs != null) numbers.push(`total ${response.totalTimeMs}ms`);
|
|
112
|
+
if (response.executionTimeMs != null) numbers.push(`exec ${response.executionTimeMs}ms`);
|
|
113
|
+
if (response.planningTimeMs != null) numbers.push(`plan ${response.planningTimeMs}ms`);
|
|
114
|
+
if (response.estimatedRows != null) numbers.push(`est ${response.estimatedRows} rows`);
|
|
115
|
+
if (response.actualRows != null) numbers.push(`actual ${response.actualRows} rows`);
|
|
116
|
+
if (response.nodeCount != null) numbers.push(`${response.nodeCount} nodes`);
|
|
117
|
+
if (numbers.length) stdout.write(`Timings: ${numbers.join(", ")}\n\n`);
|
|
118
|
+
|
|
119
|
+
if (response.aiSummary) {
|
|
120
|
+
stdout.write(`Summary:\n${response.aiSummary.trim()}\n\n`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const issues = Array.isArray(response.issues) ? response.issues : [];
|
|
124
|
+
if (issues.length > 0) {
|
|
125
|
+
stdout.write(`Issues (${issues.length}):\n`);
|
|
126
|
+
for (const issue of issues) {
|
|
127
|
+
const label = issue.severity ? `[${issue.severity}] ` : "";
|
|
128
|
+
stdout.write(` • ${label}${issue.title || issue.message || JSON.stringify(issue)}\n`);
|
|
129
|
+
if (issue.message && issue.title) stdout.write(` ${issue.message}\n`);
|
|
130
|
+
}
|
|
131
|
+
stdout.write("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const recs = Array.isArray(response.indexRecommendations) ? response.indexRecommendations : [];
|
|
135
|
+
if (recs.length > 0) {
|
|
136
|
+
stdout.write(`Suggested indexes (${recs.length}):\n`);
|
|
137
|
+
for (const r of recs) {
|
|
138
|
+
const cols = Array.isArray(r.columns) ? r.columns.join(", ") : (r.columnNames || "?");
|
|
139
|
+
stdout.write(` • ${r.tableName || "?"}(${cols})`);
|
|
140
|
+
if (r.estimatedImpact != null) stdout.write(` impact≈${r.estimatedImpact}`);
|
|
141
|
+
stdout.write("\n");
|
|
142
|
+
if (r.suggestedSql) stdout.write(` ${r.suggestedSql.trim()}\n`);
|
|
143
|
+
}
|
|
144
|
+
stdout.write("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const tips = Array.isArray(response.optimizationSuggestions) ? response.optimizationSuggestions : [];
|
|
148
|
+
if (tips.length > 0) {
|
|
149
|
+
stdout.write(`Suggestions:\n`);
|
|
150
|
+
for (const t of tips) stdout.write(` • ${t}\n`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (response.aiOptimization) {
|
|
154
|
+
stdout.write(`\nOptimization narrative:\n${response.aiOptimization.trim()}\n`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readSqlInput(opts) {
|
|
159
|
+
if (opts.file) return fs.readFileSync(opts.file, "utf8");
|
|
160
|
+
if (opts.positional.length > 0) return opts.positional.join(" ");
|
|
161
|
+
if (!process.stdin.isTTY) return fs.readFileSync(0, "utf8");
|
|
162
|
+
throw new Error("Pass SQL as an argument, via --file <path>, or pipe it to stdin.");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { run };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
|
|
6
|
+
const { parseArgs, buildOpts } = require("../cli");
|
|
7
|
+
|
|
8
|
+
function opts(argv) {
|
|
9
|
+
return buildOpts(parseArgs(argv));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function loadWithStubs({ responses = [], onRequest = () => {}, errors = [], confirmAnswer = false }) {
|
|
13
|
+
for (const k of [
|
|
14
|
+
require.resolve("../api/client"),
|
|
15
|
+
require.resolve("./_session"),
|
|
16
|
+
require.resolve("./_connections"),
|
|
17
|
+
require.resolve("../ui/prompts"),
|
|
18
|
+
require.resolve("./analyze"),
|
|
19
|
+
]) {
|
|
20
|
+
delete require.cache[k];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const apiKey = require.resolve("../api/client");
|
|
24
|
+
let i = 0;
|
|
25
|
+
class FakeApiError extends Error {
|
|
26
|
+
constructor(message, { status, body } = {}) { super(message); this.status = status; this.body = body; }
|
|
27
|
+
}
|
|
28
|
+
require.cache[apiKey] = {
|
|
29
|
+
id: apiKey, filename: apiKey, loaded: true,
|
|
30
|
+
exports: {
|
|
31
|
+
ApiError: FakeApiError,
|
|
32
|
+
async request(_base, path, body) {
|
|
33
|
+
onRequest(path, body);
|
|
34
|
+
if (errors[i]) {
|
|
35
|
+
const e = errors[i];
|
|
36
|
+
i++;
|
|
37
|
+
throw new FakeApiError(e.message || "fail", { status: e.status, body: e.body });
|
|
38
|
+
}
|
|
39
|
+
const r = responses[i] ?? {};
|
|
40
|
+
i++;
|
|
41
|
+
return r;
|
|
42
|
+
},
|
|
43
|
+
setClientContext() {},
|
|
44
|
+
getClientContext() { return null; },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const sessKey = require.resolve("./_session");
|
|
49
|
+
require.cache[sessKey] = {
|
|
50
|
+
id: sessKey, filename: sessKey, loaded: true,
|
|
51
|
+
exports: { resolveSession: () => ({ baseUrl: "http://test", token: "t" }) },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const connKey = require.resolve("./_connections");
|
|
55
|
+
require.cache[connKey] = {
|
|
56
|
+
id: connKey, filename: connKey, loaded: true,
|
|
57
|
+
exports: { resolveConnectionId: async () => "00000000-0000-0000-0000-000000000001" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const promptsKey = require.resolve("../ui/prompts");
|
|
61
|
+
require.cache[promptsKey] = {
|
|
62
|
+
id: promptsKey, filename: promptsKey, loaded: true,
|
|
63
|
+
exports: { confirm: async () => confirmAnswer, input: async () => "", password: async () => "", select: async () => "" },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return require("./analyze");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function captureStdout() {
|
|
70
|
+
let out = ""; let err = "";
|
|
71
|
+
return {
|
|
72
|
+
stream: { write: (s) => { out += s; } },
|
|
73
|
+
errStream: { write: (s) => { err += s; } },
|
|
74
|
+
out: () => out, err: () => err,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test("analyze hits /explain/analyze with useAnalyze=false by default", async () => {
|
|
79
|
+
const seen = [];
|
|
80
|
+
const analyze = loadWithStubs({
|
|
81
|
+
onRequest: (path, body) => seen.push({ path, body: body && body.json }),
|
|
82
|
+
responses: [{ planText: "Seq Scan on orders" }],
|
|
83
|
+
});
|
|
84
|
+
const io = captureStdout();
|
|
85
|
+
await analyze.run(opts(["SELECT * FROM orders"]), { stdout: io.stream, stderr: io.errStream });
|
|
86
|
+
assert.equal(seen.length, 1);
|
|
87
|
+
assert.match(seen[0].path, /\/explain\/analyze$/);
|
|
88
|
+
assert.equal(seen[0].body.useAnalyze, false);
|
|
89
|
+
assert.match(io.out(), /Plan \(EXPLAIN, not executed\)/);
|
|
90
|
+
assert.match(io.out(), /Seq Scan on orders/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("analyze --analyze flips useAnalyze=true on the request", async () => {
|
|
94
|
+
const seen = [];
|
|
95
|
+
const analyze = loadWithStubs({
|
|
96
|
+
onRequest: (path, body) => seen.push(body && body.json),
|
|
97
|
+
responses: [{ wasExecuted: true, planText: "executed" }],
|
|
98
|
+
});
|
|
99
|
+
const io = captureStdout();
|
|
100
|
+
await analyze.run(opts(["SELECT * FROM orders", "--analyze"]), { stdout: io.stream });
|
|
101
|
+
assert.equal(seen[0].useAnalyze, true);
|
|
102
|
+
assert.match(io.out(), /Plan \(executed via EXPLAIN ANALYZE\)/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("analyze on a mutation triggers two-step confirmation flow on 4xx requiresConfirmation", async () => {
|
|
106
|
+
const seen = [];
|
|
107
|
+
const analyze = loadWithStubs({
|
|
108
|
+
onRequest: (_path, body) => seen.push(body && body.json),
|
|
109
|
+
errors: [{
|
|
110
|
+
status: 200,
|
|
111
|
+
body: { requiresConfirmation: true, message: "ANALYZE will execute this mutation.", queryType: "DELETE", warnings: ["no WHERE"] },
|
|
112
|
+
message: "confirmation required",
|
|
113
|
+
}, null],
|
|
114
|
+
responses: [null, { wasExecuted: true, planText: "deleted 1" }],
|
|
115
|
+
confirmAnswer: true,
|
|
116
|
+
});
|
|
117
|
+
const io = captureStdout();
|
|
118
|
+
await analyze.run(opts(["DELETE FROM users WHERE id=1", "--analyze"]), { stdout: io.stream, stderr: io.errStream });
|
|
119
|
+
assert.equal(seen.length, 2);
|
|
120
|
+
assert.equal(seen[0].mutationConfirmed, false);
|
|
121
|
+
assert.equal(seen[1].mutationConfirmed, true);
|
|
122
|
+
assert.match(io.err(), /ANALYZE will execute/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("analyze --write skips the prompt and bakes confirmMutation into the first call", async () => {
|
|
126
|
+
const seen = [];
|
|
127
|
+
const analyze = loadWithStubs({
|
|
128
|
+
onRequest: (_path, body) => seen.push(body && body.json),
|
|
129
|
+
responses: [{ wasExecuted: true, planText: "ok" }],
|
|
130
|
+
});
|
|
131
|
+
const io = captureStdout();
|
|
132
|
+
await analyze.run(opts(["DELETE FROM users WHERE id=1", "--analyze", "--write"]), { stdout: io.stream });
|
|
133
|
+
assert.equal(seen.length, 1);
|
|
134
|
+
assert.equal(seen[0].mutationConfirmed, true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("analyze --json emits the raw analysis payload", async () => {
|
|
138
|
+
const analyze = loadWithStubs({
|
|
139
|
+
responses: [{ aiSummary: "fast", nodeCount: 3, issues: [], indexRecommendations: [] }],
|
|
140
|
+
});
|
|
141
|
+
const io = captureStdout();
|
|
142
|
+
// SQL first so `--json` doesn't eat it as a value.
|
|
143
|
+
await analyze.run(opts(["SELECT 1", "--json"]), { stdout: io.stream });
|
|
144
|
+
const parsed = JSON.parse(io.out());
|
|
145
|
+
assert.equal(parsed.nodeCount, 3);
|
|
146
|
+
assert.equal(parsed.aiSummary, "fast");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("analyze renders AI summary, issues, and index recommendations from the response", async () => {
|
|
150
|
+
const analyze = loadWithStubs({
|
|
151
|
+
responses: [{
|
|
152
|
+
planText: "Seq Scan",
|
|
153
|
+
aiSummary: "Sequential scan on a large table; consider an index on customer_id.",
|
|
154
|
+
issues: [{ severity: "HIGH", title: "Sequential scan", message: "10M rows scanned" }],
|
|
155
|
+
indexRecommendations: [{ tableName: "orders", columns: ["customer_id"], estimatedImpact: 80 }],
|
|
156
|
+
optimizationSuggestions: ["Add WHERE customer_id = ?"],
|
|
157
|
+
}],
|
|
158
|
+
});
|
|
159
|
+
const io = captureStdout();
|
|
160
|
+
await analyze.run(opts(["SELECT * FROM orders"]), { stdout: io.stream });
|
|
161
|
+
const out = io.out();
|
|
162
|
+
assert.match(out, /Summary:/);
|
|
163
|
+
assert.match(out, /Sequential scan on a large table/);
|
|
164
|
+
assert.match(out, /\[HIGH\] Sequential scan/);
|
|
165
|
+
assert.match(out, /orders\(customer_id\)/);
|
|
166
|
+
assert.match(out, /impact≈80/);
|
|
167
|
+
assert.match(out, /Add WHERE customer_id/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test.after(() => {
|
|
171
|
+
for (const k of [
|
|
172
|
+
require.resolve("../api/client"),
|
|
173
|
+
require.resolve("./_session"),
|
|
174
|
+
require.resolve("./_connections"),
|
|
175
|
+
require.resolve("../ui/prompts"),
|
|
176
|
+
require.resolve("./analyze"),
|
|
177
|
+
]) {
|
|
178
|
+
delete require.cache[k];
|
|
179
|
+
}
|
|
180
|
+
});
|
package/src/commands/explain.js
CHANGED
|
@@ -1,41 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql explain` — deprecated alias for `deepsql analyze`.
|
|
5
|
+
*
|
|
6
|
+
* In 0.13.0 we consolidated SQL execution and plan analysis under two
|
|
7
|
+
* canonical commands: `deepsql query` (executes anything) and
|
|
8
|
+
* `deepsql analyze` (AI-enriched plan analysis, with optional ANALYZE).
|
|
9
|
+
* `explain` was a thin read-only-locked subset of analyze, so it lives on
|
|
10
|
+
* for one cycle as a forwarder while users migrate; it will be removed in
|
|
11
|
+
* 0.14.0.
|
|
12
|
+
*/
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
const sql = readSqlInput(opts);
|
|
11
|
-
// EXPLAIN ANALYZE is mutating; require plain EXPLAIN.
|
|
12
|
-
const validation = validateReadOnlySql(sql, { allowExplain: false });
|
|
13
|
-
if (!validation.ok) throw new Error(validation.reason);
|
|
14
|
+
const analyze = require("./analyze");
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (opts.json) {
|
|
24
|
-
stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
if (response?.plan) {
|
|
28
|
-
stdout.write(typeof response.plan === "string" ? `${response.plan}\n` : `${JSON.stringify(response.plan, null, 2)}\n`);
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function readSqlInput(opts) {
|
|
35
|
-
if (opts.file) return fs.readFileSync(opts.file, "utf8");
|
|
36
|
-
if (opts.positional.length > 0) return opts.positional.join(" ");
|
|
37
|
-
if (!process.stdin.isTTY) return fs.readFileSync(0, "utf8");
|
|
38
|
-
throw new Error("Pass SQL as an argument, via --file <path>, or pipe it to stdin.");
|
|
16
|
+
async function run(opts, io = {}) {
|
|
17
|
+
const stderr = io.stderr || process.stderr;
|
|
18
|
+
stderr.write(
|
|
19
|
+
"[deepsql] `deepsql explain` is deprecated and will be removed in 0.14.0. "
|
|
20
|
+
+ "Use `deepsql analyze` (same behavior; add `--analyze` for EXPLAIN ANALYZE).\n",
|
|
21
|
+
);
|
|
22
|
+
return analyze.run(opts, io);
|
|
39
23
|
}
|
|
40
24
|
|
|
41
25
|
module.exports = { run };
|