@deepsql/mcp 0.16.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 +3 -1
- 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 +30 -0
- package/src/commands/growth.js +458 -0
- package/src/commands/growth.test.js +439 -0
- package/src/commands/mcp.js +9 -9
- package/src/commands/slow-queries.js +116 -1
- package/src/user-home.js +29 -0
|
@@ -0,0 +1,439 @@
|
|
|
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
|
+
// ─── argv plumbing ─────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
test("growth flags: --table, --days, --unack, --json land in opts", () => {
|
|
15
|
+
const o = opts(["anomalies", "--table", "orders", "--days", "14", "--unack", "--json"]);
|
|
16
|
+
assert.equal(o.table, "orders");
|
|
17
|
+
assert.equal(o.days, "14");
|
|
18
|
+
assert.equal(o.unack, true);
|
|
19
|
+
assert.equal(o.json, true);
|
|
20
|
+
assert.deepEqual(o.positional, ["anomalies"]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("growth ack <id> keeps the id as positional[1]", () => {
|
|
24
|
+
const o = opts(["ack", "anomaly-abc-123"]);
|
|
25
|
+
assert.deepEqual(o.positional, ["ack", "anomaly-abc-123"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("growth config set --file <path> threads the file flag", () => {
|
|
29
|
+
const o = opts(["config", "set", "--file", "/tmp/alert.json"]);
|
|
30
|
+
assert.deepEqual(o.positional, ["config", "set"]);
|
|
31
|
+
assert.equal(o.file, "/tmp/alert.json");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ─── dispatch with fake api/client ─────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
function loadWithStubs({ requests = [], responses = {}, error } = {}) {
|
|
37
|
+
for (const k of [
|
|
38
|
+
require.resolve("../api/client"),
|
|
39
|
+
require.resolve("./_session"),
|
|
40
|
+
require.resolve("./_connections"),
|
|
41
|
+
require.resolve("./growth"),
|
|
42
|
+
]) {
|
|
43
|
+
delete require.cache[k];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class ApiError extends Error {
|
|
47
|
+
constructor(message, { status, body } = {}) { super(message); this.status = status; this.body = body; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const apiKey = require.resolve("../api/client");
|
|
51
|
+
require.cache[apiKey] = {
|
|
52
|
+
id: apiKey, filename: apiKey, loaded: true,
|
|
53
|
+
exports: {
|
|
54
|
+
ApiError,
|
|
55
|
+
async request(_base, path, options) {
|
|
56
|
+
requests.push({ path, options });
|
|
57
|
+
if (error) throw error;
|
|
58
|
+
if (Object.prototype.hasOwnProperty.call(responses, path)) {
|
|
59
|
+
return responses[path];
|
|
60
|
+
}
|
|
61
|
+
return {};
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const sessKey = require.resolve("./_session");
|
|
67
|
+
require.cache[sessKey] = {
|
|
68
|
+
id: sessKey, filename: sessKey, loaded: true,
|
|
69
|
+
exports: { resolveSession: () => ({ baseUrl: "http://test", token: "t" }) },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const connKey = require.resolve("./_connections");
|
|
73
|
+
require.cache[connKey] = {
|
|
74
|
+
id: connKey, filename: connKey, loaded: true,
|
|
75
|
+
exports: {
|
|
76
|
+
resolveConnectionId: async () => "00000000-0000-0000-0000-000000000001",
|
|
77
|
+
listConnections: async () => [],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return { growth: require("./growth"), ApiError };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function captureStdout() {
|
|
85
|
+
let out = ""; let err = "";
|
|
86
|
+
return {
|
|
87
|
+
stream: { write: (s) => { out += s; } },
|
|
88
|
+
errStream: { write: (s) => { err += s; } },
|
|
89
|
+
out: () => out,
|
|
90
|
+
err: () => err,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── trends ────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
test("growth trends (default) hits /growth-monitoring/trends/{cid} with default 30-day window", async () => {
|
|
97
|
+
const requests = [];
|
|
98
|
+
const { growth } = loadWithStubs({
|
|
99
|
+
requests,
|
|
100
|
+
responses: {
|
|
101
|
+
"/growth-monitoring/trends/00000000-0000-0000-0000-000000000001": {
|
|
102
|
+
trends: { sizeOverTime: [] },
|
|
103
|
+
days: 30,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const io = captureStdout();
|
|
108
|
+
await growth.run(opts([]), { stdout: io.stream }); // default subcommand = trends
|
|
109
|
+
assert.equal(requests.length, 1);
|
|
110
|
+
assert.match(requests[0].path, /^\/growth-monitoring\/trends\//);
|
|
111
|
+
// --days falls back to 30, --table is null (omitted from query).
|
|
112
|
+
assert.equal(requests[0].options.query.days, 30);
|
|
113
|
+
assert.equal(requests[0].options.query.tableName, null);
|
|
114
|
+
assert.match(io.out(), /No growth data/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("growth trends renders per-table headline sorted by absolute delta", async () => {
|
|
118
|
+
const { growth } = loadWithStubs({
|
|
119
|
+
responses: {
|
|
120
|
+
"/growth-monitoring/trends/00000000-0000-0000-0000-000000000001": {
|
|
121
|
+
trends: {
|
|
122
|
+
sizeOverTime: [
|
|
123
|
+
// orders: tiny growth
|
|
124
|
+
{ table: "orders", timestamp: "2026-04-15T00:00:00", sizeBytes: 100_000_000 },
|
|
125
|
+
{ table: "orders", timestamp: "2026-05-15T00:00:00", sizeBytes: 110_000_000 },
|
|
126
|
+
// events_log: huge growth — should sort first
|
|
127
|
+
{ table: "events_log", timestamp: "2026-04-15T00:00:00", sizeBytes: 1_000_000_000 },
|
|
128
|
+
{ table: "events_log", timestamp: "2026-05-15T00:00:00", sizeBytes: 5_000_000_000 },
|
|
129
|
+
// archive: shrunk
|
|
130
|
+
{ table: "archive", timestamp: "2026-04-15T00:00:00", sizeBytes: 200_000_000 },
|
|
131
|
+
{ table: "archive", timestamp: "2026-05-15T00:00:00", sizeBytes: 50_000_000 },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
days: 30,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const io = captureStdout();
|
|
139
|
+
await growth.run(opts([]), { stdout: io.stream });
|
|
140
|
+
const text = io.out();
|
|
141
|
+
// events_log absolute delta = 4GB (biggest), sorts first
|
|
142
|
+
const evIdx = text.indexOf("events_log");
|
|
143
|
+
const archIdx = text.indexOf("archive");
|
|
144
|
+
const ordIdx = text.indexOf("orders");
|
|
145
|
+
assert.ok(evIdx >= 0 && archIdx >= 0 && ordIdx >= 0);
|
|
146
|
+
assert.ok(evIdx < archIdx, "events_log (4GB delta) should sort before archive (150MB shrink)");
|
|
147
|
+
assert.ok(archIdx < ordIdx, "archive (150MB shrink) should sort before orders (10MB)");
|
|
148
|
+
assert.match(text, /↑ events_log/);
|
|
149
|
+
assert.match(text, /↓ archive/);
|
|
150
|
+
assert.match(text, /↑ orders/);
|
|
151
|
+
// Headline includes the formatted byte sizes + signed percent. Bytes are
|
|
152
|
+
// rendered in 1024-base (953.7 MB for 1e9 bytes, 4.66 GB for 5e9 bytes).
|
|
153
|
+
assert.match(text, /events_log\s+\S*\s+953\.7 MB → 4\.66 GB/);
|
|
154
|
+
assert.match(text, /\+400\.0%/); // 1e9 → 5e9 = +400%
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("growth trends --table scopes the request and --days is respected", async () => {
|
|
158
|
+
const requests = [];
|
|
159
|
+
const { growth } = loadWithStubs({
|
|
160
|
+
requests,
|
|
161
|
+
responses: {
|
|
162
|
+
"/growth-monitoring/trends/00000000-0000-0000-0000-000000000001": { trends: { sizeOverTime: [] } },
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
await growth.run(opts(["trends", "--table", "orders", "--days", "7"]), { stdout: captureStdout().stream });
|
|
166
|
+
assert.equal(requests[0].options.query.tableName, "orders");
|
|
167
|
+
assert.equal(requests[0].options.query.days, 7);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ─── history ───────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
test("growth history hits the /history endpoint with default 7-day window", async () => {
|
|
173
|
+
const requests = [];
|
|
174
|
+
const { growth } = loadWithStubs({
|
|
175
|
+
requests,
|
|
176
|
+
responses: {
|
|
177
|
+
"/growth-monitoring/history/00000000-0000-0000-0000-000000000001": { history: [] },
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
await growth.run(opts(["history"]), { stdout: captureStdout().stream });
|
|
181
|
+
assert.equal(requests[0].options.query.days, 7);
|
|
182
|
+
assert.match(requests[0].path, /^\/growth-monitoring\/history\//);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("growth history renders snapshot rows with timestamp, size, rows, and growth delta", async () => {
|
|
186
|
+
const { growth } = loadWithStubs({
|
|
187
|
+
responses: {
|
|
188
|
+
"/growth-monitoring/history/00000000-0000-0000-0000-000000000001": {
|
|
189
|
+
history: [
|
|
190
|
+
{
|
|
191
|
+
snapshotTimestamp: "2026-05-14T03:00:00",
|
|
192
|
+
tableName: "orders",
|
|
193
|
+
sizeBytes: 110_000_000,
|
|
194
|
+
rowCount: 850_000,
|
|
195
|
+
sizeGrowthPercent: 10.5,
|
|
196
|
+
bloatPercent: 12.3,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
const io = captureStdout();
|
|
203
|
+
await growth.run(opts(["history"]), { stdout: io.stream });
|
|
204
|
+
const text = io.out();
|
|
205
|
+
assert.match(text, /2026-05-14 03:00:00/);
|
|
206
|
+
assert.match(text, /orders/);
|
|
207
|
+
assert.match(text, /104\.9 MB/);
|
|
208
|
+
assert.match(text, /850,000 rows/);
|
|
209
|
+
assert.match(text, /\+10\.5%/);
|
|
210
|
+
assert.match(text, /bloat 12\.3%/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ─── anomalies ─────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
test("growth anomalies summary surfaces severity counts + worst marker", async () => {
|
|
216
|
+
const { growth } = loadWithStubs({
|
|
217
|
+
responses: {
|
|
218
|
+
"/growth-monitoring/anomalies/00000000-0000-0000-0000-000000000001": {
|
|
219
|
+
anomalies: [
|
|
220
|
+
{
|
|
221
|
+
id: "a1", tableName: "events_log", severity: "CRITICAL",
|
|
222
|
+
anomalyType: "PERCENTAGE_GROWTH",
|
|
223
|
+
detectionTimestamp: "2026-05-14T03:00:00",
|
|
224
|
+
previousSizeBytes: 1_000_000_000,
|
|
225
|
+
currentSizeBytes: 4_000_000_000,
|
|
226
|
+
sizeGrowthPercent: 300,
|
|
227
|
+
description: "events_log grew 300% in 24h — checking for ingestion runaway.",
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: "a2", tableName: "orders", severity: "WARNING",
|
|
231
|
+
anomalyType: "STATISTICAL_ANOMALY",
|
|
232
|
+
detectionTimestamp: "2026-05-14T02:00:00",
|
|
233
|
+
description: "growth z-score above threshold",
|
|
234
|
+
acknowledged: true,
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
statistics: { total: 2, critical: 1, warning: 1, unacknowledged: 1 },
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
const io = captureStdout();
|
|
242
|
+
await growth.run(opts(["anomalies"]), { stdout: io.stream });
|
|
243
|
+
const text = io.out();
|
|
244
|
+
assert.match(text, /2 anomalies \(1 critical, 1 warning, 1 unacknowledged\)/);
|
|
245
|
+
assert.match(text, /✗ \[CRITICAL\]/);
|
|
246
|
+
assert.match(text, /⚠ \[WARNING\]/);
|
|
247
|
+
assert.match(text, /events_log/);
|
|
248
|
+
// 1e9 → 4e9 bytes in 1024-base = 953.7 MB → 3.73 GB.
|
|
249
|
+
assert.match(text, /953\.7 MB → 3\.73 GB/);
|
|
250
|
+
assert.match(text, /\+300\.0%/);
|
|
251
|
+
// Acked anomaly should be marked
|
|
252
|
+
assert.match(text, /\[acked\]/);
|
|
253
|
+
// Hint pointing at `ack`
|
|
254
|
+
assert.match(text, /deepsql growth ack/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("growth anomalies --unack threads the unacknowledgedOnly flag", async () => {
|
|
258
|
+
const requests = [];
|
|
259
|
+
const { growth } = loadWithStubs({
|
|
260
|
+
requests,
|
|
261
|
+
responses: {
|
|
262
|
+
"/growth-monitoring/anomalies/00000000-0000-0000-0000-000000000001": { anomalies: [] },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
await growth.run(opts(["anomalies", "--unack"]), { stdout: captureStdout().stream });
|
|
266
|
+
assert.equal(requests[0].options.query.unacknowledgedOnly, "true");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ─── ack ───────────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
test("growth ack <id> POSTs to the right endpoint", async () => {
|
|
272
|
+
const requests = [];
|
|
273
|
+
const { growth } = loadWithStubs({
|
|
274
|
+
requests,
|
|
275
|
+
responses: {
|
|
276
|
+
"/growth-monitoring/anomalies/anomaly-x/acknowledge": { success: true },
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
await growth.run(opts(["ack", "anomaly-x"]), { stdout: captureStdout().stream });
|
|
280
|
+
assert.equal(requests.length, 1);
|
|
281
|
+
assert.equal(requests[0].path, "/growth-monitoring/anomalies/anomaly-x/acknowledge");
|
|
282
|
+
assert.equal(requests[0].options.method, "POST");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("growth ack without an id throws a friendly Usage:", async () => {
|
|
286
|
+
const { growth } = loadWithStubs({});
|
|
287
|
+
await assert.rejects(
|
|
288
|
+
growth.run(opts(["ack"]), { stdout: captureStdout().stream }),
|
|
289
|
+
/Usage: deepsql growth ack <anomalyId>/,
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ─── capture ───────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
test("growth capture POSTs to /capture/{cid} and prints the async-job hint", async () => {
|
|
296
|
+
const requests = [];
|
|
297
|
+
const { growth } = loadWithStubs({
|
|
298
|
+
requests,
|
|
299
|
+
responses: {
|
|
300
|
+
"/growth-monitoring/capture/00000000-0000-0000-0000-000000000001": {
|
|
301
|
+
message: "Snapshot capture requested for connection 00000000…",
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const io = captureStdout();
|
|
306
|
+
await growth.run(opts(["capture"]), { stdout: io.stream });
|
|
307
|
+
assert.equal(requests[0].options.method, "POST");
|
|
308
|
+
assert.match(io.out(), /Snapshot capture requested/);
|
|
309
|
+
assert.match(io.out(), /runs asynchronously/);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ─── config ────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
test("growth config show with no config rows nudges toward `config set`", async () => {
|
|
315
|
+
const { growth } = loadWithStubs({
|
|
316
|
+
responses: {
|
|
317
|
+
"/growth-monitoring/config/00000000-0000-0000-0000-000000000001": {
|
|
318
|
+
configurations: [],
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
const io = captureStdout();
|
|
323
|
+
await growth.run(opts(["config", "show"]), { stdout: io.stream });
|
|
324
|
+
assert.match(io.out(), /No growth-monitoring configurations/);
|
|
325
|
+
assert.match(io.out(), /deepsql growth config set --file/);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("growth config show pretty-prints a single configuration when --table is set", async () => {
|
|
329
|
+
const { growth } = loadWithStubs({
|
|
330
|
+
responses: {
|
|
331
|
+
"/growth-monitoring/config/00000000-0000-0000-0000-000000000001": {
|
|
332
|
+
configuration: {
|
|
333
|
+
tableName: "orders",
|
|
334
|
+
percentageGrowthWarning: 20,
|
|
335
|
+
percentageGrowthCritical: 50,
|
|
336
|
+
absoluteGrowthWarningBytes: 100_000_000,
|
|
337
|
+
absoluteGrowthCriticalBytes: 1_000_000_000,
|
|
338
|
+
zScoreThreshold: 3.0,
|
|
339
|
+
isEnabled: true,
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
const io = captureStdout();
|
|
345
|
+
await growth.run(opts(["config", "show", "--table", "orders"]), { stdout: io.stream });
|
|
346
|
+
const text = io.out();
|
|
347
|
+
assert.match(text, /table=orders/);
|
|
348
|
+
assert.match(text, /warning=20%/);
|
|
349
|
+
assert.match(text, /critical=50%/);
|
|
350
|
+
assert.match(text, /enabled: true/);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("growth config set --file requires the flag and a valid connectionId in the JSON body", async () => {
|
|
354
|
+
const { growth } = loadWithStubs({});
|
|
355
|
+
await assert.rejects(
|
|
356
|
+
growth.run(opts(["config", "set"]), { stdout: captureStdout().stream }),
|
|
357
|
+
/Usage: deepsql growth config set --file/,
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ─── 403 / 404 wrapping ───────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
test("growth wraps 403 with a permissions-shaped error", async () => {
|
|
364
|
+
// The trick here: growth.js destructures `ApiError` and `request` at
|
|
365
|
+
// require time, so we need the stubbed module to expose BOTH from the
|
|
366
|
+
// start. `loadWithStubs` already exports its own ApiError class on the
|
|
367
|
+
// stubbed module — we just need `request` to throw an INSTANCE of that
|
|
368
|
+
// exact class (so growth.js's `err instanceof ApiError` check passes).
|
|
369
|
+
//
|
|
370
|
+
// We do this by registering an api/client stub whose request() looks
|
|
371
|
+
// up its own exported ApiError and constructs the error from it. This
|
|
372
|
+
// works because the stub object is the same module reference growth.js
|
|
373
|
+
// sees, so its `ApiError` ref and our stub's `ApiError` ref are
|
|
374
|
+
// identical.
|
|
375
|
+
for (const k of [
|
|
376
|
+
require.resolve("../api/client"),
|
|
377
|
+
require.resolve("./_session"),
|
|
378
|
+
require.resolve("./_connections"),
|
|
379
|
+
require.resolve("./growth"),
|
|
380
|
+
]) {
|
|
381
|
+
delete require.cache[k];
|
|
382
|
+
}
|
|
383
|
+
class ApiError extends Error {
|
|
384
|
+
constructor(message, { status, body } = {}) { super(message); this.status = status; this.body = body; }
|
|
385
|
+
}
|
|
386
|
+
const apiKey = require.resolve("../api/client");
|
|
387
|
+
require.cache[apiKey] = {
|
|
388
|
+
id: apiKey, filename: apiKey, loaded: true,
|
|
389
|
+
exports: {
|
|
390
|
+
ApiError,
|
|
391
|
+
async request() {
|
|
392
|
+
throw new ApiError("forbidden", { status: 403 });
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
const sessKey = require.resolve("./_session");
|
|
397
|
+
require.cache[sessKey] = {
|
|
398
|
+
id: sessKey, filename: sessKey, loaded: true,
|
|
399
|
+
exports: { resolveSession: () => ({ baseUrl: "http://test", token: "t" }) },
|
|
400
|
+
};
|
|
401
|
+
const connKey = require.resolve("./_connections");
|
|
402
|
+
require.cache[connKey] = {
|
|
403
|
+
id: connKey, filename: connKey, loaded: true,
|
|
404
|
+
exports: { resolveConnectionId: async () => "conn-x", listConnections: async () => [] },
|
|
405
|
+
};
|
|
406
|
+
const growth = require("./growth");
|
|
407
|
+
|
|
408
|
+
await assert.rejects(
|
|
409
|
+
growth.run(opts(["anomalies"]), { stdout: captureStdout().stream }),
|
|
410
|
+
/Access denied — growth-monitoring requires permissions/,
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("growth --json emits the raw backend body without prose", async () => {
|
|
415
|
+
const { growth } = loadWithStubs({
|
|
416
|
+
responses: {
|
|
417
|
+
"/growth-monitoring/anomalies/00000000-0000-0000-0000-000000000001": {
|
|
418
|
+
anomalies: [{ id: "x", tableName: "t" }],
|
|
419
|
+
statistics: { total: 1 },
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
const io = captureStdout();
|
|
424
|
+
await growth.run(opts(["anomalies", "--json"]), { stdout: io.stream });
|
|
425
|
+
const parsed = JSON.parse(io.out());
|
|
426
|
+
assert.equal(parsed.anomalies.length, 1);
|
|
427
|
+
assert.equal(parsed.statistics.total, 1);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test.after(() => {
|
|
431
|
+
for (const k of [
|
|
432
|
+
require.resolve("../api/client"),
|
|
433
|
+
require.resolve("./_session"),
|
|
434
|
+
require.resolve("./_connections"),
|
|
435
|
+
require.resolve("./growth"),
|
|
436
|
+
]) {
|
|
437
|
+
delete require.cache[k];
|
|
438
|
+
}
|
|
439
|
+
});
|
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 };
|