@deepsql/mcp 0.10.1 → 0.11.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/src/cli.test.js CHANGED
@@ -3,7 +3,18 @@
3
3
  const test = require("node:test");
4
4
  const assert = require("node:assert/strict");
5
5
 
6
- const { parseArgs, buildOpts } = require("./cli");
6
+ const { parseArgs, buildOpts, main, renderRootHelp, renderCommandHelp, COMMAND_HELP } = require("./cli");
7
+
8
+ function captureStreams() {
9
+ let out = "";
10
+ let err = "";
11
+ return {
12
+ stdout: { write: (s) => { out += s; }, isTTY: false },
13
+ stderr: { write: (s) => { err += s; }, isTTY: false },
14
+ out: () => out,
15
+ err: () => err,
16
+ };
17
+ }
7
18
 
8
19
  test("parseArgs collects positional args", () => {
9
20
  const { positional, flags } = parseArgs(["how", "many", "rows"]);
@@ -36,3 +47,72 @@ test("buildOpts maps known flags", () => {
36
47
  assert.equal(opts.limit, "50");
37
48
  assert.deepEqual(opts.positional, ["SELECT 1"]);
38
49
  });
50
+
51
+ test("renderRootHelp lists every command and marks subcommand-bearing ones with *", () => {
52
+ const text = renderRootHelp(false);
53
+ assert.match(text, /🐬 DeepSQL/);
54
+ assert.match(text, /Hint: commands suffixed with \* have subcommands/);
55
+ // Leaf command — no star.
56
+ assert.match(text, /\n\s+login\s+ /);
57
+ // Parent commands — must carry a star.
58
+ for (const name of ["config", "connections", "digest", "users", "access", "permissions", "slow-queries"]) {
59
+ assert.match(text, new RegExp(`\\n\\s+${name} \\*`), `expected "${name} *" in root help`);
60
+ }
61
+ });
62
+
63
+ test("renderCommandHelp emits subcommands and options for parent commands", () => {
64
+ const text = renderCommandHelp("connections", false);
65
+ assert.match(text, /Usage: deepsql connections/);
66
+ assert.match(text, /Subcommands:/);
67
+ assert.match(text, /Options:/);
68
+ assert.match(text, /list \[--json\]/);
69
+ });
70
+
71
+ test("every command in the catalog has a COMMAND_HELP entry", () => {
72
+ // Sourced from the dispatcher map so the lists stay in lockstep.
73
+ const { main: _main } = require("./cli");
74
+ void _main;
75
+ const expected = [
76
+ "login","logout","whoami","config","mcp","connections","query","explain","schema",
77
+ "digest","brain-context","business-rules","relationships","anti-patterns","indexes",
78
+ "users","access","permissions","slow-queries","setup",
79
+ ];
80
+ for (const name of expected) {
81
+ assert.ok(COMMAND_HELP[name], `missing COMMAND_HELP for ${name}`);
82
+ assert.ok(COMMAND_HELP[name].usage, `missing usage for ${name}`);
83
+ }
84
+ });
85
+
86
+ test("main prints root help on no args and on --help", async () => {
87
+ for (const argv of [[], ["--help"], ["-h"]]) {
88
+ const io = captureStreams();
89
+ const code = await main(argv, io);
90
+ assert.equal(code, 0);
91
+ assert.match(io.out(), /Usage: deepsql \[options\] \[command\]/);
92
+ }
93
+ });
94
+
95
+ test("main prints per-command help when --help follows the command", async () => {
96
+ const io = captureStreams();
97
+ const code = await main(["connections", "--help"], io);
98
+ assert.equal(code, 0);
99
+ assert.match(io.out(), /Usage: deepsql connections/);
100
+ assert.match(io.out(), /Subcommands:/);
101
+ });
102
+
103
+ test("main rejects unknown commands and shows the root help", async () => {
104
+ const io = captureStreams();
105
+ const code = await main(["nope-not-real"], io);
106
+ assert.equal(code, 2);
107
+ assert.match(io.err(), /Unknown command: nope-not-real/);
108
+ assert.match(io.err(), /Usage: deepsql/);
109
+ });
110
+
111
+ test("--no-color suppresses ANSI escapes in help output", async () => {
112
+ const io = captureStreams();
113
+ // Force a TTY so the only thing turning color off is --no-color itself.
114
+ io.stdout.isTTY = true;
115
+ await main(["--help", "--no-color"], io);
116
+ // No ESC bytes anywhere.
117
+ assert.equal(/\x1b\[/.test(io.out()), false, "help output should be plain when --no-color is set");
118
+ });
@@ -0,0 +1,306 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `deepsql indexes` — index suggestions, usage, and health (read-only V1).
5
+ *
6
+ * Subcommands:
7
+ * list [--all | --status PENDING|APPLIED|DISMISSED]
8
+ * GET /index-recommendations/{cid} (or /pending/{cid})
9
+ * missing GET /advisor/indexes/{cid} (missing-index advisor)
10
+ * health GET /index-advisor/{cid}/health-report
11
+ * unused GET /index-advisor/{cid}/unused
12
+ * duplicates GET /index-advisor/{cid}/duplicates
13
+ * usage <table> GET /index-advisor/{cid}/usage/{tableName}
14
+ *
15
+ * Everything is read-only by design — V1 surfaces suggestions only. Apply,
16
+ * dismiss, delete, generate, and the estimate-create/estimate-drop endpoints
17
+ * are intentionally not wired up here; they'll land in a later pass once we
18
+ * have UX for confirming mutations.
19
+ */
20
+
21
+ const { request } = require("../api/client");
22
+ const { resolveSession } = require("./_session");
23
+ const { resolveConnectionId } = require("./_connections");
24
+
25
+ const VALID_STATUSES = new Set(["PENDING", "APPLIED", "DISMISSED"]);
26
+
27
+ const SUBCOMMANDS = {
28
+ list: cmdList,
29
+ missing: cmdMissing,
30
+ health: cmdHealth,
31
+ unused: cmdUnused,
32
+ duplicates: cmdDuplicates,
33
+ usage: cmdUsage,
34
+ };
35
+
36
+ async function run(opts, io = {}) {
37
+ const sub = opts.positional[0] || "list";
38
+ const handler = SUBCOMMANDS[sub];
39
+ if (!handler) {
40
+ throw new Error(
41
+ `Unknown indexes subcommand: ${sub}. Try \`list\`, \`missing\`, \`health\`, \`unused\`, \`duplicates\`, or \`usage <table>\`.`,
42
+ );
43
+ }
44
+ return handler({ ...opts, positional: opts.positional.slice(1) }, io);
45
+ }
46
+
47
+ // ─── list ──────────────────────────────────────────────────────────────────
48
+
49
+ async function cmdList(opts, { stdout = process.stdout } = {}) {
50
+ const session = resolveSession(opts);
51
+ const connectionId = await resolveConnectionId(session, opts.connection);
52
+
53
+ const wantAll = !!opts.all || (opts.status && opts.status !== "PENDING");
54
+ const statusFilter = opts.status ? String(opts.status).toUpperCase() : null;
55
+ if (statusFilter && !VALID_STATUSES.has(statusFilter)) {
56
+ throw new Error(
57
+ `Invalid --status "${opts.status}". One of: ${[...VALID_STATUSES].join(", ")}.`,
58
+ );
59
+ }
60
+
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
+ const path = wantAll
65
+ ? `/index-recommendations/${encodeURIComponent(connectionId)}`
66
+ : `/index-recommendations/pending/${encodeURIComponent(connectionId)}`;
67
+
68
+ const response = await request(session.baseUrl, path, { token: session.token });
69
+ let list = Array.isArray(response) ? response : [];
70
+ if (statusFilter && statusFilter !== "PENDING") {
71
+ list = list.filter((r) => String(r.status || "").toUpperCase() === statusFilter);
72
+ }
73
+
74
+ if (opts.json) {
75
+ stdout.write(`${JSON.stringify(list, null, 2)}\n`);
76
+ return;
77
+ }
78
+
79
+ if (list.length === 0) {
80
+ const scope = statusFilter || (wantAll ? "any" : "pending");
81
+ stdout.write(`No ${scope.toLowerCase()} index recommendations for this connection.\n`);
82
+ return;
83
+ }
84
+
85
+ const byPriority = (a, b) => priorityWeight(b.priority) - priorityWeight(a.priority);
86
+ list.sort(byPriority);
87
+
88
+ const scopeLabel = statusFilter || (wantAll ? "all" : "pending");
89
+ const noun = list.length === 1 ? "recommendation" : "recommendations";
90
+ stdout.write(`${list.length} ${scopeLabel} ${noun}:\n\n`);
91
+
92
+ for (const r of list) {
93
+ const prio = r.priority ? `[${r.priority}]` : "[?]";
94
+ const cols = r.columnNames || "?";
95
+ const status = r.status ? ` (${r.status})` : "";
96
+ const impact = r.estimatedImpact != null ? `, impact≈${r.estimatedImpact}%` : "";
97
+ const affected = r.affectedQueries != null ? `, ${r.affectedQueries} queries` : "";
98
+ stdout.write(`${prio} ${r.tableName || "?"}(${cols})${status}\n`);
99
+ if (r.indexName) stdout.write(` name: ${r.indexName}\n`);
100
+ if (r.reason) stdout.write(` reason: ${r.reason}${impact}${affected}\n`);
101
+ if (r.createStatement) stdout.write(` ddl: ${oneLine(r.createStatement)}\n`);
102
+ if (r.id) stdout.write(` id: ${r.id}\n`);
103
+ stdout.write("\n");
104
+ }
105
+ }
106
+
107
+ // ─── missing ───────────────────────────────────────────────────────────────
108
+
109
+ async function cmdMissing(opts, { stdout = process.stdout } = {}) {
110
+ const session = resolveSession(opts);
111
+ const connectionId = await resolveConnectionId(session, opts.connection);
112
+
113
+ const response = await request(
114
+ session.baseUrl,
115
+ `/advisor/indexes/${encodeURIComponent(connectionId)}`,
116
+ { token: session.token },
117
+ );
118
+ const list = Array.isArray(response) ? response : [];
119
+
120
+ if (opts.json) {
121
+ stdout.write(`${JSON.stringify(list, null, 2)}\n`);
122
+ return;
123
+ }
124
+ if (list.length === 0) {
125
+ stdout.write("No missing-index suggestions for this connection.\n");
126
+ return;
127
+ }
128
+
129
+ stdout.write(`${list.length} missing-index ${list.length === 1 ? "suggestion" : "suggestions"}:\n\n`);
130
+ for (const r of list) {
131
+ const prio = r.priority ? `[${r.priority}]` : "[?]";
132
+ const cols = formatColumns(r.columnNames || r.columns);
133
+ stdout.write(`${prio} ${r.tableName || "?"}(${cols})\n`);
134
+ if (r.reason) stdout.write(` reason: ${r.reason}\n`);
135
+ if (r.estimatedImpact != null) stdout.write(` impact: ${r.estimatedImpact}\n`);
136
+ if (r.suggestedIndex) stdout.write(` ddl: ${oneLine(r.suggestedIndex)}\n`);
137
+ stdout.write("\n");
138
+ }
139
+ }
140
+
141
+ // ─── health ────────────────────────────────────────────────────────────────
142
+
143
+ async function cmdHealth(opts, { stdout = process.stdout } = {}) {
144
+ const session = resolveSession(opts);
145
+ const connectionId = await resolveConnectionId(session, opts.connection);
146
+
147
+ const response = await request(
148
+ session.baseUrl,
149
+ `/index-advisor/${encodeURIComponent(connectionId)}/health-report`,
150
+ { token: session.token },
151
+ );
152
+
153
+ if (opts.json) {
154
+ stdout.write(`${JSON.stringify(response, null, 2)}\n`);
155
+ return;
156
+ }
157
+ if (!response || typeof response !== "object") {
158
+ stdout.write("No health report available.\n");
159
+ return;
160
+ }
161
+
162
+ stdout.write("Index health report\n");
163
+ stdout.write("───────────────────\n");
164
+ for (const [key, value] of Object.entries(response)) {
165
+ if (value == null) continue;
166
+ if (Array.isArray(value)) {
167
+ stdout.write(`${pad(key)} ${value.length} entries\n`);
168
+ } else if (typeof value === "object") {
169
+ stdout.write(`${pad(key)} ${JSON.stringify(value)}\n`);
170
+ } else {
171
+ stdout.write(`${pad(key)} ${value}\n`);
172
+ }
173
+ }
174
+ }
175
+
176
+ // ─── unused ────────────────────────────────────────────────────────────────
177
+
178
+ async function cmdUnused(opts, { stdout = process.stdout } = {}) {
179
+ const session = resolveSession(opts);
180
+ const connectionId = await resolveConnectionId(session, opts.connection);
181
+
182
+ const response = await request(
183
+ session.baseUrl,
184
+ `/index-advisor/${encodeURIComponent(connectionId)}/unused`,
185
+ { token: session.token },
186
+ );
187
+ const list = Array.isArray(response) ? response : [];
188
+
189
+ if (opts.json) {
190
+ stdout.write(`${JSON.stringify(list, null, 2)}\n`);
191
+ return;
192
+ }
193
+ if (list.length === 0) {
194
+ stdout.write("No unused indexes detected.\n");
195
+ return;
196
+ }
197
+
198
+ stdout.write(`${list.length} unused ${list.length === 1 ? "index" : "indexes"}:\n\n`);
199
+ for (const idx of list) {
200
+ const size = idx.sizeMb != null ? `, ${idx.sizeMb} MB` : (idx.indexSize ? `, ${idx.indexSize}` : "");
201
+ const scans = idx.scans != null ? `, scans=${idx.scans}` : (idx.indexScans != null ? `, scans=${idx.indexScans}` : "");
202
+ stdout.write(
203
+ ` • ${idx.tableName || idx.table || "?"}.${idx.indexName || idx.name || "?"}${size}${scans}\n`,
204
+ );
205
+ }
206
+ }
207
+
208
+ // ─── duplicates ────────────────────────────────────────────────────────────
209
+
210
+ async function cmdDuplicates(opts, { stdout = process.stdout } = {}) {
211
+ const session = resolveSession(opts);
212
+ const connectionId = await resolveConnectionId(session, opts.connection);
213
+
214
+ const response = await request(
215
+ session.baseUrl,
216
+ `/index-advisor/${encodeURIComponent(connectionId)}/duplicates`,
217
+ { token: session.token },
218
+ );
219
+ const list = Array.isArray(response) ? response : [];
220
+
221
+ if (opts.json) {
222
+ stdout.write(`${JSON.stringify(list, null, 2)}\n`);
223
+ return;
224
+ }
225
+ if (list.length === 0) {
226
+ stdout.write("No duplicate or redundant indexes detected.\n");
227
+ return;
228
+ }
229
+
230
+ stdout.write(`${list.length} duplicate ${list.length === 1 ? "group" : "groups"}:\n\n`);
231
+ for (const dup of list) {
232
+ const table = dup.tableName || dup.table || "?";
233
+ const names = Array.isArray(dup.indexes)
234
+ ? dup.indexes.map((i) => (typeof i === "string" ? i : i.indexName || i.name)).join(", ")
235
+ : (dup.indexName || dup.name || "?");
236
+ stdout.write(` • ${table}: ${names}\n`);
237
+ if (dup.reason) stdout.write(` ${dup.reason}\n`);
238
+ }
239
+ }
240
+
241
+ // ─── usage <table> ─────────────────────────────────────────────────────────
242
+
243
+ async function cmdUsage(opts, { stdout = process.stdout } = {}) {
244
+ const tableName = opts.positional[0];
245
+ if (!tableName) {
246
+ throw new Error("Usage: deepsql indexes usage <tableName> --connection <name> [--json]");
247
+ }
248
+ const session = resolveSession(opts);
249
+ const connectionId = await resolveConnectionId(session, opts.connection);
250
+
251
+ const response = await request(
252
+ session.baseUrl,
253
+ `/index-advisor/${encodeURIComponent(connectionId)}/usage/${encodeURIComponent(tableName)}`,
254
+ { token: session.token },
255
+ );
256
+ const list = Array.isArray(response) ? response : [];
257
+
258
+ if (opts.json) {
259
+ stdout.write(`${JSON.stringify(list, null, 2)}\n`);
260
+ return;
261
+ }
262
+ if (list.length === 0) {
263
+ stdout.write(`No index usage stats for ${tableName}.\n`);
264
+ return;
265
+ }
266
+
267
+ stdout.write(`Index usage for ${tableName} (${list.length} ${list.length === 1 ? "index" : "indexes"}):\n\n`);
268
+ for (const idx of list) {
269
+ const scans = idx.scans ?? idx.indexScans ?? "?";
270
+ const reads = idx.tuplesRead ?? idx.tupReads ?? null;
271
+ const fetched = idx.tuplesFetched ?? idx.tupFetched ?? null;
272
+ const extra = [
273
+ `scans=${scans}`,
274
+ reads != null ? `reads=${reads}` : null,
275
+ fetched != null ? `fetched=${fetched}` : null,
276
+ ].filter(Boolean).join(", ");
277
+ stdout.write(` • ${idx.indexName || idx.name || "?"} — ${extra}\n`);
278
+ }
279
+ }
280
+
281
+ // ─── helpers ───────────────────────────────────────────────────────────────
282
+
283
+ function priorityWeight(p) {
284
+ switch (String(p || "").toUpperCase()) {
285
+ case "CRITICAL": return 4;
286
+ case "HIGH": return 3;
287
+ case "MEDIUM": return 2;
288
+ case "LOW": return 1;
289
+ default: return 0;
290
+ }
291
+ }
292
+
293
+ function oneLine(s) {
294
+ return String(s).replace(/\s+/g, " ").trim();
295
+ }
296
+
297
+ function formatColumns(cols) {
298
+ if (Array.isArray(cols)) return cols.join(", ");
299
+ return cols || "?";
300
+ }
301
+
302
+ function pad(key) {
303
+ return `${key}:`.padEnd(28);
304
+ }
305
+
306
+ module.exports = { run };
@@ -0,0 +1,298 @@
1
+ "use strict";
2
+
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const Module = require("node:module");
6
+
7
+ const { parseArgs, buildOpts } = require("../cli");
8
+
9
+ function opts(argv) {
10
+ return buildOpts(parseArgs(argv));
11
+ }
12
+
13
+ // ─── argv plumbing ─────────────────────────────────────────────────────────
14
+
15
+ test("indexes flags: --all, --status, --json land in opts", () => {
16
+ const o = opts(["list", "--all", "--status", "APPLIED", "--json"]);
17
+ assert.equal(o.all, true);
18
+ assert.equal(o.status, "APPLIED");
19
+ assert.equal(o.json, true);
20
+ assert.deepEqual(o.positional, ["list"]);
21
+ });
22
+
23
+ test("indexes usage <table> keeps the table name as positional[1]", () => {
24
+ const o = opts(["usage", "users", "--connection", "c1"]);
25
+ assert.deepEqual(o.positional, ["usage", "users"]);
26
+ assert.equal(o.connection, "c1");
27
+ });
28
+
29
+ // ─── dispatch ──────────────────────────────────────────────────────────────
30
+ //
31
+ // Build a fake api/client + _session + _connections so the `run` handlers
32
+ // don't try to hit the network. We stub them via Node's `require.cache` so
33
+ // they intercept the next require() inside indexes.js.
34
+
35
+ function loadWithStubs({ requests = [], responses = {} }) {
36
+ // Reset the modules we're about to stub + the indexes module itself.
37
+ for (const k of [
38
+ require.resolve("../api/client"),
39
+ require.resolve("./_session"),
40
+ require.resolve("./_connections"),
41
+ require.resolve("./indexes"),
42
+ ]) {
43
+ delete require.cache[k];
44
+ }
45
+
46
+ const apiKey = require.resolve("../api/client");
47
+ require.cache[apiKey] = {
48
+ id: apiKey,
49
+ filename: apiKey,
50
+ loaded: true,
51
+ exports: {
52
+ ApiError: class ApiError extends Error {},
53
+ async request(_base, path) {
54
+ requests.push(path);
55
+ if (Object.prototype.hasOwnProperty.call(responses, path)) {
56
+ return responses[path];
57
+ }
58
+ return [];
59
+ },
60
+ },
61
+ };
62
+
63
+ const sessKey = require.resolve("./_session");
64
+ require.cache[sessKey] = {
65
+ id: sessKey,
66
+ filename: sessKey,
67
+ loaded: true,
68
+ exports: {
69
+ resolveSession: () => ({ baseUrl: "http://test", token: "t" }),
70
+ },
71
+ };
72
+
73
+ const connKey = require.resolve("./_connections");
74
+ require.cache[connKey] = {
75
+ id: connKey,
76
+ filename: connKey,
77
+ loaded: true,
78
+ exports: {
79
+ resolveConnectionId: async () => "00000000-0000-0000-0000-000000000001",
80
+ listConnections: async () => [],
81
+ },
82
+ };
83
+
84
+ return require("./indexes");
85
+ }
86
+
87
+ function captureStdout() {
88
+ let out = "";
89
+ return {
90
+ stream: { write: (s) => { out += s; } },
91
+ out: () => out,
92
+ };
93
+ }
94
+
95
+ test("indexes list (default) hits the /pending endpoint", async () => {
96
+ const requests = [];
97
+ const indexes = loadWithStubs({
98
+ requests,
99
+ responses: {
100
+ "/index-recommendations/pending/00000000-0000-0000-0000-000000000001": [],
101
+ },
102
+ });
103
+ const stdout = captureStdout();
104
+ await indexes.run(opts(["list"]), { stdout: stdout.stream });
105
+ assert.equal(requests.length, 1);
106
+ assert.match(requests[0], /\/index-recommendations\/pending\//);
107
+ assert.match(stdout.out(), /No pending index recommendations/);
108
+ });
109
+
110
+ test("indexes list --all hits the all-recs endpoint", async () => {
111
+ const requests = [];
112
+ const indexes = loadWithStubs({
113
+ requests,
114
+ responses: {
115
+ "/index-recommendations/00000000-0000-0000-0000-000000000001": [],
116
+ },
117
+ });
118
+ const stdout = captureStdout();
119
+ await indexes.run(opts(["list", "--all"]), { stdout: stdout.stream });
120
+ assert.equal(requests.length, 1);
121
+ assert.equal(requests[0], "/index-recommendations/00000000-0000-0000-0000-000000000001");
122
+ });
123
+
124
+ test("indexes list renders priority, name, ddl per recommendation", async () => {
125
+ const indexes = loadWithStubs({
126
+ responses: {
127
+ "/index-recommendations/pending/00000000-0000-0000-0000-000000000001": [
128
+ {
129
+ id: "r1",
130
+ tableName: "orders",
131
+ columnNames: "customer_id, created_at",
132
+ indexName: "idx_orders_customer_created",
133
+ createStatement: "CREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at)",
134
+ priority: "HIGH",
135
+ estimatedImpact: 42,
136
+ affectedQueries: 7,
137
+ reason: "Hot lookup pattern from chat history",
138
+ status: "PENDING",
139
+ },
140
+ ],
141
+ },
142
+ });
143
+ const stdout = captureStdout();
144
+ await indexes.run(opts(["list"]), { stdout: stdout.stream });
145
+ const text = stdout.out();
146
+ assert.match(text, /\[HIGH\] orders\(customer_id, created_at\)/);
147
+ assert.match(text, /idx_orders_customer_created/);
148
+ assert.match(text, /CREATE INDEX idx_orders_customer_created/);
149
+ assert.match(text, /impact≈42%/);
150
+ assert.match(text, /7 queries/);
151
+ });
152
+
153
+ test("indexes list --status filters client-side and switches to the all endpoint", async () => {
154
+ const requests = [];
155
+ const indexes = loadWithStubs({
156
+ requests,
157
+ responses: {
158
+ "/index-recommendations/00000000-0000-0000-0000-000000000001": [
159
+ { id: "a", tableName: "t1", columnNames: "x", priority: "LOW", status: "APPLIED" },
160
+ { id: "b", tableName: "t2", columnNames: "y", priority: "HIGH", status: "DISMISSED" },
161
+ ],
162
+ },
163
+ });
164
+ const stdout = captureStdout();
165
+ await indexes.run(opts(["list", "--status", "DISMISSED"]), { stdout: stdout.stream });
166
+ assert.match(requests[0], /\/index-recommendations\/00000000/);
167
+ const text = stdout.out();
168
+ assert.match(text, /1 DISMISSED recommendation/);
169
+ assert.match(text, /\[HIGH\] t2/);
170
+ assert.equal(/t1/.test(text), false, "applied row should be filtered out");
171
+ });
172
+
173
+ test("indexes list rejects bogus --status values", async () => {
174
+ const indexes = loadWithStubs({});
175
+ await assert.rejects(
176
+ indexes.run(opts(["list", "--status", "REJECTED"]), { stdout: captureStdout().stream }),
177
+ /Invalid --status/,
178
+ );
179
+ });
180
+
181
+ test("indexes usage <table> wires the table into the URL", async () => {
182
+ const requests = [];
183
+ const indexes = loadWithStubs({
184
+ requests,
185
+ responses: {
186
+ "/index-advisor/00000000-0000-0000-0000-000000000001/usage/users": [],
187
+ },
188
+ });
189
+ const stdout = captureStdout();
190
+ await indexes.run(opts(["usage", "users"]), { stdout: stdout.stream });
191
+ assert.equal(requests[0], "/index-advisor/00000000-0000-0000-0000-000000000001/usage/users");
192
+ });
193
+
194
+ test("indexes usage without a table name throws a friendly Usage:", async () => {
195
+ const indexes = loadWithStubs({});
196
+ await assert.rejects(
197
+ indexes.run(opts(["usage"]), { stdout: captureStdout().stream }),
198
+ /Usage: deepsql indexes usage <tableName>/,
199
+ );
200
+ });
201
+
202
+ test("indexes unused prints size and scan counts in the text view", async () => {
203
+ const indexes = loadWithStubs({
204
+ responses: {
205
+ "/index-advisor/00000000-0000-0000-0000-000000000001/unused": [
206
+ { tableName: "orders", indexName: "idx_old", sizeMb: 120, scans: 0 },
207
+ ],
208
+ },
209
+ });
210
+ const stdout = captureStdout();
211
+ await indexes.run(opts(["unused"]), { stdout: stdout.stream });
212
+ assert.match(stdout.out(), /orders\.idx_old, 120 MB, scans=0/);
213
+ });
214
+
215
+ test("indexes duplicates handles the empty case", async () => {
216
+ const indexes = loadWithStubs({
217
+ responses: {
218
+ "/index-advisor/00000000-0000-0000-0000-000000000001/duplicates": [],
219
+ },
220
+ });
221
+ const stdout = captureStdout();
222
+ await indexes.run(opts(["duplicates"]), { stdout: stdout.stream });
223
+ assert.match(stdout.out(), /No duplicate or redundant indexes detected\./);
224
+ });
225
+
226
+ test("indexes health flattens the report into key: value lines", async () => {
227
+ const indexes = loadWithStubs({
228
+ responses: {
229
+ "/index-advisor/00000000-0000-0000-0000-000000000001/health-report": {
230
+ totalIndexes: 47,
231
+ unusedCount: 3,
232
+ duplicateGroups: 1,
233
+ items: [{ a: 1 }, { a: 2 }],
234
+ },
235
+ },
236
+ });
237
+ const stdout = captureStdout();
238
+ await indexes.run(opts(["health"]), { stdout: stdout.stream });
239
+ const text = stdout.out();
240
+ assert.match(text, /Index health report/);
241
+ assert.match(text, /totalIndexes:.*47/);
242
+ assert.match(text, /items:.*2 entries/);
243
+ });
244
+
245
+ test("indexes missing renders advisor-style suggestions", async () => {
246
+ const indexes = loadWithStubs({
247
+ responses: {
248
+ "/advisor/indexes/00000000-0000-0000-0000-000000000001": [
249
+ { tableName: "users", columns: ["email"], priority: "CRITICAL", reason: "Unindexed equality lookup", suggestedIndex: "CREATE INDEX idx_users_email ON users(email)" },
250
+ ],
251
+ },
252
+ });
253
+ const stdout = captureStdout();
254
+ await indexes.run(opts(["missing"]), { stdout: stdout.stream });
255
+ const text = stdout.out();
256
+ assert.match(text, /\[CRITICAL\] users\(email\)/);
257
+ assert.match(text, /idx_users_email/);
258
+ });
259
+
260
+ test("indexes <unknown-sub> throws a hint that lists the real ones", async () => {
261
+ const indexes = loadWithStubs({});
262
+ await assert.rejects(
263
+ indexes.run(opts(["nope"]), { stdout: captureStdout().stream }),
264
+ /Unknown indexes subcommand: nope/,
265
+ );
266
+ });
267
+
268
+ test("indexes list --json emits raw JSON and no human prose", async () => {
269
+ const indexes = loadWithStubs({
270
+ responses: {
271
+ "/index-recommendations/pending/00000000-0000-0000-0000-000000000001": [
272
+ { id: "r1", tableName: "t", columnNames: "x" },
273
+ ],
274
+ },
275
+ });
276
+ const stdout = captureStdout();
277
+ await indexes.run(opts(["list", "--json"]), { stdout: stdout.stream });
278
+ const text = stdout.out().trim();
279
+ const parsed = JSON.parse(text);
280
+ assert.equal(parsed.length, 1);
281
+ assert.equal(parsed[0].id, "r1");
282
+ });
283
+
284
+ // Keep the suite hermetic — flush the stubs back so later test files that
285
+ // require these modules don't pick up our fakes.
286
+ test.after(() => {
287
+ for (const k of [
288
+ require.resolve("../api/client"),
289
+ require.resolve("./_session"),
290
+ require.resolve("./_connections"),
291
+ require.resolve("./indexes"),
292
+ ]) {
293
+ delete require.cache[k];
294
+ }
295
+ });
296
+
297
+ // Quieten the unused-Module warning — we may use it later.
298
+ void Module;