@deepsql/mcp 0.14.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `deepsql growth` — table growth analytics.
5
+ *
6
+ * Backend has a full `/growth-monitoring/*` controller with size/row time
7
+ * series, anomaly detection (sudden growth spikes flagged by severity),
8
+ * predictions, and per-table alert configs. Until this command shipped,
9
+ * none of that was reachable from the CLI or the MCP — the agent and
10
+ * terminal users could only get to it by hand-rolling SQL against
11
+ * `table_stats_history`. Now they can ask DeepSQL directly.
12
+ *
13
+ * trends [--connection <c>] [--table <t>] [--days N=30] GET /growth-monitoring/trends/{cid}
14
+ * history [--connection <c>] [--table <t>] [--days N=7] GET /growth-monitoring/history/{cid}
15
+ * anomalies [--connection <c>] [--table <t>] [--unack] [--days N=30] GET /growth-monitoring/anomalies/{cid}
16
+ * ack <anomalyId> POST /growth-monitoring/anomalies/{id}/acknowledge
17
+ * capture [--connection <c>] POST /growth-monitoring/capture/{cid} (admin)
18
+ * config show [--connection <c>] [--table <t>] GET /growth-monitoring/config/{cid}
19
+ * config set --file <path> POST /growth-monitoring/config (admin)
20
+ *
21
+ * `capture` and `config set` are the only mutations; both are admin-level
22
+ * but neither writes user data, so we don't gate them behind --confirm.
23
+ * The other subcommands are pure reads.
24
+ */
25
+
26
+ const fs = require("node:fs");
27
+ const { ApiError, request } = require("../api/client");
28
+ const { resolveSession } = require("./_session");
29
+ const { resolveConnectionId } = require("./_connections");
30
+
31
+ const SUBCOMMANDS = {
32
+ trends: cmdTrends,
33
+ history: cmdHistory,
34
+ anomalies: cmdAnomalies,
35
+ ack: cmdAck,
36
+ capture: cmdCapture,
37
+ config: cmdConfig,
38
+ };
39
+
40
+ async function run(opts, io = {}) {
41
+ const sub = opts.positional[0] || "trends";
42
+ const handler = SUBCOMMANDS[sub];
43
+ if (!handler) {
44
+ throw new Error(
45
+ `Unknown growth subcommand: ${sub}. ` +
46
+ `Try one of: trends, history, anomalies, ack <id>, capture, config show|set.`,
47
+ );
48
+ }
49
+ return wrap(handler)({ ...opts, positional: opts.positional.slice(1) }, io);
50
+ }
51
+
52
+ function wrap(handler) {
53
+ return async (opts, io) => {
54
+ try {
55
+ return await handler(opts, io);
56
+ } catch (err) {
57
+ if (err instanceof ApiError && err.status === 403) {
58
+ throw new Error(
59
+ "Access denied — growth-monitoring requires permissions on this connection.",
60
+ );
61
+ }
62
+ if (err instanceof ApiError && err.status === 404) {
63
+ throw new Error(err.message || "Resource not found.");
64
+ }
65
+ throw err;
66
+ }
67
+ };
68
+ }
69
+
70
+ // ─── trends ────────────────────────────────────────────────────────────────
71
+ //
72
+ // The most agent-friendly subcommand: rolls up the time series into per-
73
+ // table headlines ("orders: +18% / 30d, 240MB → 285MB"). Default subcommand
74
+ // because "what's growing?" is the most common question.
75
+
76
+ async function cmdTrends(opts, { stdout = process.stdout } = {}) {
77
+ const session = resolveSession(opts);
78
+ const connectionId = await resolveConnectionId(session, opts.connection);
79
+ const days = clampDays(opts.days, 30);
80
+
81
+ const response = await request(
82
+ session.baseUrl,
83
+ `/growth-monitoring/trends/${encodeURIComponent(connectionId)}`,
84
+ {
85
+ token: session.token,
86
+ query: { tableName: opts.table || null, days },
87
+ },
88
+ );
89
+
90
+ if (opts.json) {
91
+ stdout.write(`${JSON.stringify(response, null, 2)}\n`);
92
+ return;
93
+ }
94
+
95
+ const trends = response?.trends || {};
96
+ const sizeOverTime = Array.isArray(trends.sizeOverTime) ? trends.sizeOverTime : [];
97
+ if (sizeOverTime.length === 0) {
98
+ stdout.write(
99
+ `No growth data for this connection in the last ${days} day(s). ` +
100
+ `Run \`deepsql growth capture --connection <c>\` to take a fresh ` +
101
+ `snapshot if monitoring is enabled, or wait for the scheduled cycle.\n`,
102
+ );
103
+ return;
104
+ }
105
+
106
+ // Group by table and compute first / last snapshot per table.
107
+ const byTable = new Map();
108
+ for (const point of sizeOverTime) {
109
+ const t = point.table || "(unknown)";
110
+ if (!byTable.has(t)) byTable.set(t, []);
111
+ byTable.get(t).push(point);
112
+ }
113
+
114
+ // Sort tables by absolute growth (largest first) so the most-changed
115
+ // tables sit at the top of the output — what an agent or operator
116
+ // actually needs to see first.
117
+ const rows = [];
118
+ for (const [table, points] of byTable.entries()) {
119
+ points.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
120
+ const first = points[0];
121
+ const last = points[points.length - 1];
122
+ const firstBytes = first?.sizeBytes ?? 0;
123
+ const lastBytes = last?.sizeBytes ?? 0;
124
+ const deltaBytes = lastBytes - firstBytes;
125
+ const deltaPct = firstBytes > 0 ? (deltaBytes / firstBytes) * 100 : null;
126
+ rows.push({
127
+ table,
128
+ firstBytes,
129
+ lastBytes,
130
+ deltaBytes,
131
+ deltaPct,
132
+ snapshots: points.length,
133
+ });
134
+ }
135
+ rows.sort((a, b) => Math.abs(b.deltaBytes) - Math.abs(a.deltaBytes));
136
+
137
+ stdout.write(
138
+ `${rows.length} table${rows.length === 1 ? "" : "s"} with growth data ` +
139
+ `over the last ${days} day(s):\n\n`,
140
+ );
141
+ for (const r of rows) {
142
+ const arrow = r.deltaBytes >= 0 ? "↑" : "↓";
143
+ const pct = r.deltaPct != null ? signedPct(r.deltaPct) : "n/a";
144
+ stdout.write(
145
+ ` ${arrow} ${r.table.padEnd(40)} ` +
146
+ `${formatBytes(r.firstBytes)} → ${formatBytes(r.lastBytes)} ` +
147
+ `(${pct}, ${formatBytes(Math.abs(r.deltaBytes))} ${arrow === "↑" ? "added" : "freed"}, ` +
148
+ `${r.snapshots} snapshots)\n`,
149
+ );
150
+ }
151
+ stdout.write(
152
+ `\nUse \`deepsql growth history --table <name>\` for per-snapshot detail, ` +
153
+ `or \`deepsql growth anomalies\` for sudden spikes.\n`,
154
+ );
155
+ }
156
+
157
+ // ─── history ───────────────────────────────────────────────────────────────
158
+
159
+ async function cmdHistory(opts, { stdout = process.stdout } = {}) {
160
+ const session = resolveSession(opts);
161
+ const connectionId = await resolveConnectionId(session, opts.connection);
162
+ const days = clampDays(opts.days, 7);
163
+
164
+ const response = await request(
165
+ session.baseUrl,
166
+ `/growth-monitoring/history/${encodeURIComponent(connectionId)}`,
167
+ {
168
+ token: session.token,
169
+ query: { tableName: opts.table || null, days },
170
+ },
171
+ );
172
+
173
+ if (opts.json) {
174
+ stdout.write(`${JSON.stringify(response, null, 2)}\n`);
175
+ return;
176
+ }
177
+
178
+ const history = Array.isArray(response?.history) ? response.history : [];
179
+ if (history.length === 0) {
180
+ stdout.write(`No history rows in the last ${days} day(s).\n`);
181
+ return;
182
+ }
183
+
184
+ stdout.write(`${history.length} snapshot${history.length === 1 ? "" : "s"} ` +
185
+ `over ${days} day(s):\n\n`);
186
+ for (const h of history) {
187
+ const ts = (h.snapshotTimestamp || "").substring(0, 19).replace("T", " ");
188
+ const sz = formatBytes(h.sizeBytes);
189
+ const rows = h.rowCount != null ? `${formatNumber(h.rowCount)} rows` : "rows: n/a";
190
+ const growth = h.sizeGrowthPercent != null
191
+ ? ` Δ ${signedPct(h.sizeGrowthPercent)}`
192
+ : "";
193
+ const bloat = h.bloatPercent != null && h.bloatPercent > 0
194
+ ? `, bloat ${h.bloatPercent.toFixed(1)}%`
195
+ : "";
196
+ stdout.write(` ${ts} ${h.tableName.padEnd(36)} ${sz.padStart(10)} ${rows.padStart(16)}${growth}${bloat}\n`);
197
+ }
198
+ }
199
+
200
+ // ─── anomalies ─────────────────────────────────────────────────────────────
201
+
202
+ async function cmdAnomalies(opts, { stdout = process.stdout } = {}) {
203
+ const session = resolveSession(opts);
204
+ const connectionId = await resolveConnectionId(session, opts.connection);
205
+ const days = clampDays(opts.days, 30);
206
+
207
+ const response = await request(
208
+ session.baseUrl,
209
+ `/growth-monitoring/anomalies/${encodeURIComponent(connectionId)}`,
210
+ {
211
+ token: session.token,
212
+ query: {
213
+ tableName: opts.table || null,
214
+ unacknowledgedOnly: opts.unack ? "true" : null,
215
+ days,
216
+ },
217
+ },
218
+ );
219
+
220
+ if (opts.json) {
221
+ stdout.write(`${JSON.stringify(response, null, 2)}\n`);
222
+ return;
223
+ }
224
+
225
+ const anomalies = Array.isArray(response?.anomalies) ? response.anomalies : [];
226
+ const stats = response?.statistics || {};
227
+
228
+ if (anomalies.length === 0) {
229
+ const scope = opts.unack ? "unacknowledged " : "";
230
+ stdout.write(`No ${scope}anomalies in the last ${days} day(s).\n`);
231
+ return;
232
+ }
233
+
234
+ const total = stats.total ?? anomalies.length;
235
+ const crit = stats.critical ?? 0;
236
+ const warn = stats.warning ?? 0;
237
+ const unack = stats.unacknowledged ?? 0;
238
+ stdout.write(`${total} anomal${total === 1 ? "y" : "ies"} ` +
239
+ `(${crit} critical, ${warn} warning, ${unack} unacknowledged) ` +
240
+ `over ${days} day(s):\n\n`);
241
+
242
+ for (const a of anomalies) {
243
+ const sev = (a.severity || "INFO").padEnd(8);
244
+ const marker = sev.startsWith("CRITICAL") ? "✗" : sev.startsWith("WARNING") ? "⚠" : "ℹ";
245
+ const ts = (a.detectionTimestamp || "").substring(0, 19).replace("T", " ");
246
+ const type = a.anomalyType || "?";
247
+ const sizeStr = a.previousSizeBytes != null && a.currentSizeBytes != null
248
+ ? `${formatBytes(a.previousSizeBytes)} → ${formatBytes(a.currentSizeBytes)}`
249
+ : "";
250
+ const pct = a.sizeGrowthPercent != null ? ` (${signedPct(a.sizeGrowthPercent)})` : "";
251
+ const ackMark = a.acknowledged ? " [acked]" : "";
252
+ stdout.write(` ${marker} [${sev.trim()}] ${ts} ${a.tableName} ${type}${ackMark}\n`);
253
+ if (sizeStr) stdout.write(` size: ${sizeStr}${pct}\n`);
254
+ if (a.description) stdout.write(` ${a.description}\n`);
255
+ stdout.write(` id: ${a.id}\n\n`);
256
+ }
257
+
258
+ if (unack > 0) {
259
+ stdout.write(`Use \`deepsql growth ack <id>\` to acknowledge an anomaly.\n`);
260
+ }
261
+ }
262
+
263
+ // ─── ack ───────────────────────────────────────────────────────────────────
264
+
265
+ async function cmdAck(opts, { stdout = process.stdout } = {}) {
266
+ const anomalyId = opts.positional[0];
267
+ if (!anomalyId) {
268
+ throw new Error("Usage: deepsql growth ack <anomalyId>");
269
+ }
270
+ const session = resolveSession(opts);
271
+
272
+ const response = await request(
273
+ session.baseUrl,
274
+ `/growth-monitoring/anomalies/${encodeURIComponent(anomalyId)}/acknowledge`,
275
+ { method: "POST", token: session.token },
276
+ );
277
+
278
+ if (opts.json) {
279
+ stdout.write(`${JSON.stringify(response, null, 2)}\n`);
280
+ return;
281
+ }
282
+ if (response?.success) {
283
+ stdout.write(`Acknowledged anomaly ${anomalyId}.\n`);
284
+ } else {
285
+ stdout.write(`${response?.message || "Acknowledge attempt did not return success."}\n`);
286
+ process.exitCode = 1;
287
+ }
288
+ }
289
+
290
+ // ─── capture ───────────────────────────────────────────────────────────────
291
+
292
+ async function cmdCapture(opts, { stdout = process.stdout } = {}) {
293
+ const session = resolveSession(opts);
294
+ const connectionId = await resolveConnectionId(session, opts.connection);
295
+
296
+ const response = await request(
297
+ session.baseUrl,
298
+ `/growth-monitoring/capture/${encodeURIComponent(connectionId)}`,
299
+ { method: "POST", token: session.token },
300
+ );
301
+
302
+ if (opts.json) {
303
+ stdout.write(`${JSON.stringify(response, null, 2)}\n`);
304
+ return;
305
+ }
306
+ stdout.write(
307
+ `${response?.message || "Snapshot capture requested."}\n` +
308
+ `The job runs asynchronously on the backend; re-run ` +
309
+ `\`deepsql growth trends\` in a minute or two to see the new data.\n`,
310
+ );
311
+ }
312
+
313
+ // ─── config ────────────────────────────────────────────────────────────────
314
+ //
315
+ // Two flavors: `config show` (read) and `config set --file <p>` (admin
316
+ // write). The set form takes the full GrowthAlertConfiguration JSON
317
+ // body so we don't have to enumerate every threshold flag — the schema
318
+ // is wider than most CLI inputs warrant and JSON keeps us honest.
319
+
320
+ async function cmdConfig(opts, io = {}) {
321
+ const action = opts.positional[0] || "show";
322
+ if (action === "show") return cmdConfigShow(opts, io);
323
+ if (action === "set") return cmdConfigSet(opts, io);
324
+ throw new Error(`Unknown config action: ${action}. Try \`show\` or \`set --file <path>\`.`);
325
+ }
326
+
327
+ async function cmdConfigShow(opts, { stdout = process.stdout } = {}) {
328
+ const session = resolveSession(opts);
329
+ const connectionId = await resolveConnectionId(session, opts.connection);
330
+ const response = await request(
331
+ session.baseUrl,
332
+ `/growth-monitoring/config/${encodeURIComponent(connectionId)}`,
333
+ { token: session.token, query: { tableName: opts.table || null } },
334
+ );
335
+
336
+ if (opts.json) {
337
+ stdout.write(`${JSON.stringify(response, null, 2)}\n`);
338
+ return;
339
+ }
340
+
341
+ const single = response?.configuration;
342
+ const list = Array.isArray(response?.configurations) ? response.configurations : null;
343
+
344
+ if (single) {
345
+ renderConfig(stdout, single);
346
+ return;
347
+ }
348
+ if (!list || list.length === 0) {
349
+ stdout.write(
350
+ "No growth-monitoring configurations for this connection. " +
351
+ "Defaults are applied. To customize thresholds, " +
352
+ "POST a GrowthAlertConfiguration JSON via " +
353
+ "`deepsql growth config set --file <path>`.\n",
354
+ );
355
+ return;
356
+ }
357
+ stdout.write(`${list.length} alert configuration${list.length === 1 ? "" : "s"}:\n`);
358
+ for (const c of list) {
359
+ stdout.write("\n");
360
+ renderConfig(stdout, c);
361
+ }
362
+ }
363
+
364
+ async function cmdConfigSet(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
365
+ if (!opts.file) {
366
+ throw new Error(
367
+ "Usage: deepsql growth config set --file <path-to-config.json>\n" +
368
+ "The JSON body must match GrowthAlertConfiguration (connectionId required).",
369
+ );
370
+ }
371
+ let body;
372
+ try {
373
+ body = JSON.parse(fs.readFileSync(opts.file, "utf8"));
374
+ } catch (err) {
375
+ throw new Error(`Could not read/parse ${opts.file}: ${err.message}`);
376
+ }
377
+ if (!body.connectionId) {
378
+ throw new Error("Config JSON must include a `connectionId` field.");
379
+ }
380
+ const session = resolveSession(opts);
381
+ const response = await request(
382
+ session.baseUrl,
383
+ "/growth-monitoring/config",
384
+ { method: "POST", token: session.token, json: body },
385
+ );
386
+
387
+ if (opts.json) {
388
+ stdout.write(`${JSON.stringify(response, null, 2)}\n`);
389
+ return;
390
+ }
391
+ if (response?.success) {
392
+ stdout.write(`Saved growth-monitoring configuration.\n`);
393
+ if (response.configuration) renderConfig(stdout, response.configuration);
394
+ } else {
395
+ stderr.write(`${response?.message || "Save did not return success."}\n`);
396
+ process.exitCode = 1;
397
+ }
398
+ }
399
+
400
+ // ─── helpers ───────────────────────────────────────────────────────────────
401
+
402
+ function clampDays(value, fallback) {
403
+ if (value == null) return fallback;
404
+ const n = Number.parseInt(value, 10);
405
+ if (!Number.isFinite(n) || n < 1) return fallback;
406
+ return Math.min(365, n);
407
+ }
408
+
409
+ function formatBytes(bytes) {
410
+ if (bytes == null) return "?";
411
+ const abs = Math.abs(bytes);
412
+ if (abs < 1024) return `${bytes} B`;
413
+ if (abs < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
414
+ if (abs < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
415
+ if (abs < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
416
+ return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
417
+ }
418
+
419
+ function formatNumber(n) {
420
+ if (n == null) return "?";
421
+ return Number(n).toLocaleString("en-US");
422
+ }
423
+
424
+ function signedPct(n) {
425
+ if (n == null) return "n/a";
426
+ const sign = n > 0 ? "+" : "";
427
+ return `${sign}${Number(n).toFixed(1)}%`;
428
+ }
429
+
430
+ function renderConfig(stdout, c) {
431
+ const scope = c.tableName ? `table=${c.tableName}` : "connection-wide";
432
+ stdout.write(`Configuration (${scope}):\n`);
433
+ if (c.percentageGrowthWarning != null) {
434
+ stdout.write(` growth % warning=${c.percentageGrowthWarning}% critical=${c.percentageGrowthCritical}%\n`);
435
+ }
436
+ if (c.absoluteGrowthWarningBytes != null) {
437
+ stdout.write(` growth bytes warning=${formatBytes(c.absoluteGrowthWarningBytes)} critical=${formatBytes(c.absoluteGrowthCriticalBytes)}\n`);
438
+ }
439
+ if (c.rowSpikeWarning != null) {
440
+ stdout.write(` row spike warning=${formatNumber(c.rowSpikeWarning)} critical=${formatNumber(c.rowSpikeCritical)}` +
441
+ (c.rowSpikePercentage != null ? ` pct=${c.rowSpikePercentage}%` : "") + "\n");
442
+ }
443
+ if (c.zScoreThreshold != null) {
444
+ stdout.write(` z-score threshold: ${c.zScoreThreshold}\n`);
445
+ }
446
+ if (c.historicalWindowHours != null) {
447
+ stdout.write(` historical window: ${c.historicalWindowHours} hours\n`);
448
+ }
449
+ if (c.minHoursBetweenAlerts != null) {
450
+ stdout.write(` min hours between alerts: ${c.minHoursBetweenAlerts}\n`);
451
+ }
452
+ if (c.notificationChannels) stdout.write(` channels: ${c.notificationChannels}\n`);
453
+ if (c.emailRecipients) stdout.write(` email: ${c.emailRecipients}\n`);
454
+ if (c.webhookUrl) stdout.write(` webhook: ${c.webhookUrl}\n`);
455
+ stdout.write(` enabled: ${c.isEnabled !== false}\n`);
456
+ }
457
+
458
+ module.exports = { run };