@deepsql/mcp 0.13.4 → 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 +33 -10
- package/deepsql-phase1-lib.js +178 -0
- package/package.json +1 -1
- package/skills/SKILL_BODY.md +169 -33
- package/src/cli.js +36 -13
- package/src/commands/indexes.js +379 -20
- package/src/commands/indexes.test.js +231 -0
- package/src/commands/login.js +46 -7
- package/src/commands/login.test.js +111 -0
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
|
}
|
|
@@ -281,6 +281,237 @@ test("indexes list --json emits raw JSON and no human prose", async () => {
|
|
|
281
281
|
assert.equal(parsed[0].id, "r1");
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
285
|
+
// Advisor-surface tests (top / show / refresh / apply / dismiss)
|
|
286
|
+
// — absorbed from the deprecated `index-recommendations` namespace in 0.15.0.
|
|
287
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
288
|
+
|
|
289
|
+
// Richer stub that captures the full request signature (path, method, query)
|
|
290
|
+
// so the advisor tests can assert on POST vs GET, query strings, etc. Same
|
|
291
|
+
// shape as `loadWithStubs` above but with explicit request-object capture.
|
|
292
|
+
function loadWithRichStubs({ requests = [], responder = () => [] }) {
|
|
293
|
+
for (const k of [
|
|
294
|
+
require.resolve("../api/client"),
|
|
295
|
+
require.resolve("./_session"),
|
|
296
|
+
require.resolve("./_connections"),
|
|
297
|
+
require.resolve("./indexes"),
|
|
298
|
+
]) {
|
|
299
|
+
delete require.cache[k];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const apiKey = require.resolve("../api/client");
|
|
303
|
+
require.cache[apiKey] = {
|
|
304
|
+
id: apiKey,
|
|
305
|
+
filename: apiKey,
|
|
306
|
+
loaded: true,
|
|
307
|
+
exports: {
|
|
308
|
+
ApiError: class ApiError extends Error { constructor(m, s) { super(m); this.status = s; } },
|
|
309
|
+
async request(baseUrl, path, opts = {}) {
|
|
310
|
+
const captured = { path, method: opts.method || "GET", query: opts.query || null };
|
|
311
|
+
requests.push(captured);
|
|
312
|
+
return responder(captured);
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const sessKey = require.resolve("./_session");
|
|
318
|
+
require.cache[sessKey] = {
|
|
319
|
+
id: sessKey, filename: sessKey, loaded: true,
|
|
320
|
+
exports: { resolveSession: () => ({ baseUrl: "http://test", token: "t", defaultConnection: null }) },
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const connKey = require.resolve("./_connections");
|
|
324
|
+
require.cache[connKey] = {
|
|
325
|
+
id: connKey, filename: connKey, loaded: true,
|
|
326
|
+
exports: {
|
|
327
|
+
resolveConnectionId: async () => "conn-abc",
|
|
328
|
+
listConnections: async () => [],
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
return require("./indexes");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function captureStreams() {
|
|
336
|
+
let out = "";
|
|
337
|
+
let err = "";
|
|
338
|
+
return {
|
|
339
|
+
stdout: { write: (s) => { out += s; } },
|
|
340
|
+
stderr: { write: (s) => { err += s; } },
|
|
341
|
+
out: () => out,
|
|
342
|
+
err: () => err,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ─── top ───────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
test("indexes top renders workload-weighted summary with net-benefit + evidence", async () => {
|
|
349
|
+
const indexes = loadWithRichStubs({
|
|
350
|
+
responder: ({ path }) => {
|
|
351
|
+
assert.match(path, /\/index-recommendations\/conn-abc\/top$/);
|
|
352
|
+
return [{
|
|
353
|
+
id: "rec-1",
|
|
354
|
+
tableName: "orders",
|
|
355
|
+
columnNames: "customer_id,status",
|
|
356
|
+
kind: "CREATE_INDEX",
|
|
357
|
+
priority: "HIGH",
|
|
358
|
+
occurrenceCount: 4,
|
|
359
|
+
netBenefitMs: 4823000,
|
|
360
|
+
evidenceCount: 3,
|
|
361
|
+
reason: "Workload-weighted composite.",
|
|
362
|
+
hypopgBeforeCost: 1000,
|
|
363
|
+
hypopgAfterCost: 250,
|
|
364
|
+
hypopgReductionPct: 75,
|
|
365
|
+
topEvidence: [
|
|
366
|
+
{ calls: 4500, meanExecTimeMs: 850, totalExecTimeMs: 3825000, role: "WHERE_EQ" },
|
|
367
|
+
],
|
|
368
|
+
}];
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
const s = captureStreams();
|
|
372
|
+
await indexes.run(opts(["top", "--connection", "mylocalpg"]), s);
|
|
373
|
+
assert.match(s.out(), /\[CREATE\] orders\(customer_id,status\)/);
|
|
374
|
+
assert.match(s.out(), /seen 4×/);
|
|
375
|
+
assert.match(s.out(), /net=1\.3h saved/);
|
|
376
|
+
assert.match(s.out(), /HypoPG cost: 1000 → 250 \(−75\.0%\)/);
|
|
377
|
+
assert.match(s.out(), /id: rec-1/);
|
|
378
|
+
assert.match(s.out(), /top evidence: 4500 calls/);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("indexes top renders an empty-state message", async () => {
|
|
382
|
+
const indexes = loadWithRichStubs({ responder: () => [] });
|
|
383
|
+
const s = captureStreams();
|
|
384
|
+
await indexes.run(opts(["top", "--connection", "c1"]), s);
|
|
385
|
+
assert.match(s.out(), /No pending index recommendations/);
|
|
386
|
+
assert.match(s.out(), /indexes refresh/); // points at the consolidated namespace, not the deprecated one
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("indexes top --limit clamps to [1, 50]", async () => {
|
|
390
|
+
const requests = [];
|
|
391
|
+
const indexes = loadWithRichStubs({ requests, responder: () => [] });
|
|
392
|
+
const s = captureStreams();
|
|
393
|
+
await indexes.run(opts(["top", "--connection", "c1", "--limit", "999"]), s);
|
|
394
|
+
await indexes.run(opts(["top", "--connection", "c1", "--limit", "0"]), s);
|
|
395
|
+
await indexes.run(opts(["top", "--connection", "c1", "--limit", "10"]), s);
|
|
396
|
+
assert.equal(requests[0].query.limit, 50);
|
|
397
|
+
assert.equal(requests[1].query.limit, 1);
|
|
398
|
+
assert.equal(requests[2].query.limit, 10);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("indexes top --json passes the raw payload through", async () => {
|
|
402
|
+
const indexes = loadWithRichStubs({
|
|
403
|
+
responder: () => [{ id: "rec-1", tableName: "orders" }],
|
|
404
|
+
});
|
|
405
|
+
const s = captureStreams();
|
|
406
|
+
await indexes.run(opts(["top", "--connection", "c1", "--json"]), s);
|
|
407
|
+
const parsed = JSON.parse(s.out());
|
|
408
|
+
assert.equal(parsed[0].id, "rec-1");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ─── apply (the one mutation) ──────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
test("indexes apply --mode apply without --confirm refuses up-front (no API call)", async () => {
|
|
414
|
+
const requests = [];
|
|
415
|
+
const indexes = loadWithRichStubs({ requests, responder: () => ({}) });
|
|
416
|
+
const s = captureStreams();
|
|
417
|
+
await assert.rejects(
|
|
418
|
+
() => indexes.run(opts(["apply", "rec-1", "--mode", "apply"]), s),
|
|
419
|
+
/Re-run with --confirm/,
|
|
420
|
+
);
|
|
421
|
+
assert.equal(requests.length, 0, "API should not be hit without --confirm");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("indexes apply --mode dry-run defaults concurrent=true and surfaces planner-cost delta", async () => {
|
|
425
|
+
const requests = [];
|
|
426
|
+
const indexes = loadWithRichStubs({
|
|
427
|
+
requests,
|
|
428
|
+
responder: ({ path, method, query }) => {
|
|
429
|
+
assert.equal(path, "/index-recommendations/rec-1/apply");
|
|
430
|
+
assert.equal(method, "POST");
|
|
431
|
+
assert.deepEqual(query, { mode: "DRY_RUN", confirm: false, concurrent: true });
|
|
432
|
+
return {
|
|
433
|
+
recommendationId: "rec-1",
|
|
434
|
+
executedDdl: "CREATE INDEX idx_o_status ON orders (status);",
|
|
435
|
+
mode: "DRY_RUN",
|
|
436
|
+
status: "OK",
|
|
437
|
+
beforeCost: 1000,
|
|
438
|
+
afterCost: 250,
|
|
439
|
+
costReductionPct: 75,
|
|
440
|
+
samples: [{ fingerprint: "abc123def456", beforeCost: 1000, afterCost: 250 }],
|
|
441
|
+
message: "DRY_RUN complete — planner cost −75.0% (1000 → 250)",
|
|
442
|
+
};
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
const s = captureStreams();
|
|
446
|
+
await indexes.run(opts(["apply", "rec-1", "--mode", "dry-run"]), s);
|
|
447
|
+
assert.match(s.out(), /\[DRY_RUN\] OK/);
|
|
448
|
+
assert.match(s.out(), /planner cost: 1000 → 250 \(−75\.0%\)/);
|
|
449
|
+
assert.match(s.out(), /fp=abc123def456 cost 1000 → 250/);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("indexes apply --no-concurrent passes concurrent=false to the backend", async () => {
|
|
453
|
+
const requests = [];
|
|
454
|
+
const indexes = loadWithRichStubs({
|
|
455
|
+
requests,
|
|
456
|
+
responder: () => ({ mode: "APPLY", status: "OK", executedDdl: "CREATE INDEX …" }),
|
|
457
|
+
});
|
|
458
|
+
const s = captureStreams();
|
|
459
|
+
await indexes.run(
|
|
460
|
+
opts(["apply", "rec-1", "--mode", "apply", "--confirm", "--no-concurrent"]),
|
|
461
|
+
s,
|
|
462
|
+
);
|
|
463
|
+
assert.deepEqual(requests[0].query, {
|
|
464
|
+
mode: "APPLY",
|
|
465
|
+
confirm: true,
|
|
466
|
+
concurrent: false,
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("indexes apply rejects unknown modes early (no API call)", async () => {
|
|
471
|
+
const requests = [];
|
|
472
|
+
const indexes = loadWithRichStubs({ requests, responder: () => ({}) });
|
|
473
|
+
const s = captureStreams();
|
|
474
|
+
await assert.rejects(
|
|
475
|
+
() => indexes.run(opts(["apply", "rec-1", "--mode", "bogus"]), s),
|
|
476
|
+
/Unknown mode/,
|
|
477
|
+
);
|
|
478
|
+
assert.equal(requests.length, 0);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// ─── refresh / dismiss / show ──────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
test("indexes refresh POSTs to /generate and surfaces the count", async () => {
|
|
484
|
+
const indexes = loadWithRichStubs({
|
|
485
|
+
responder: ({ path, method }) => {
|
|
486
|
+
assert.equal(path, "/index-recommendations/generate/conn-abc");
|
|
487
|
+
assert.equal(method, "POST");
|
|
488
|
+
return { success: true, count: 12, message: "Generated 12 index recommendations" };
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
const s = captureStreams();
|
|
492
|
+
await indexes.run(opts(["refresh", "--connection", "mylocalpg"]), s);
|
|
493
|
+
assert.match(s.out(), /Refresh complete: 12 candidate/);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("indexes dismiss issues PUT /{id}/dismiss", async () => {
|
|
497
|
+
const requests = [];
|
|
498
|
+
const indexes = loadWithRichStubs({ requests, responder: () => ({}) });
|
|
499
|
+
const s = captureStreams();
|
|
500
|
+
await indexes.run(opts(["dismiss", "rec-9"]), s);
|
|
501
|
+
assert.equal(requests[0].path, "/index-recommendations/rec-9/dismiss");
|
|
502
|
+
assert.equal(requests[0].method, "PUT");
|
|
503
|
+
assert.match(s.out(), /Dismissed recommendation rec-9/);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("indexes show <id> requires --connection", async () => {
|
|
507
|
+
const indexes = loadWithRichStubs({});
|
|
508
|
+
const s = captureStreams();
|
|
509
|
+
await assert.rejects(
|
|
510
|
+
() => indexes.run(opts(["show", "rec-1"]), s),
|
|
511
|
+
/needs --connection/,
|
|
512
|
+
);
|
|
513
|
+
});
|
|
514
|
+
|
|
284
515
|
// Keep the suite hermetic — flush the stubs back so later test files that
|
|
285
516
|
// require these modules don't pick up our fakes.
|
|
286
517
|
test.after(() => {
|