@deepsql/mcp 0.16.0 → 0.18.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  You have two DeepSQL surfaces available:
4
4
 
5
- 1. **MCP tools** — JSON-RPC tools loaded into your session (10 of them).
5
+ 1. **MCP tools** — JSON-RPC tools loaded into your session (21 of them).
6
6
  Use these for in-session retrieval and SQL execution.
7
7
 
8
8
  2. **`deepsql` CLI** — a shell binary on the user's PATH (~19 commands).
@@ -87,6 +87,15 @@ usually doesn't know about either; that's exactly why DeepSQL exists.
87
87
  | `get_relationships(connectionId)` | Foreign keys (declared + inferred-with-confidence). |
88
88
  | `get_anti_patterns(connectionId, kind="table"\|"query")` | Patterns to avoid in this DB. |
89
89
  | `analyze_slow_queries(connectionId, thresholdMs?, limit?)` | Snapshot of slow queries from live stats. |
90
+ | `get_slow_query_timeline(connectionId, fingerprint)` | Day-by-day timeline for one fingerprint: call count, mean/max time, regression factor per day. Answers "is this query getting slower". |
91
+ | `get_query_regressions(connectionId, minFactor?)` | Slow queries that got slower on the latest daily analysis run, ranked by slowdown factor. |
92
+ | `list_tracked_queries(connectionId)` | All fingerprints in the 30-day analytics store. Browse first, then drill into one with `get_slow_query_timeline` / `get_query_samples`. |
93
+ | `get_slow_query_customers(connectionId)` | Tenants ranked by total slow-query time — answers "which customer is driving the load?" |
94
+ | `get_query_samples(connectionId, fingerprint)` | Literal SQL samples with bind values for a fingerprint, slowest-first. Use to reproduce an execution or run a real EXPLAIN. |
95
+ | `get_slow_query_insights(connectionId, kind?, window?, limit?)` | Pre-computed AI insights — `hotspots`, `remediation`, `tail-risk`, `plan-drift`, `skew`, or `all` (default). |
96
+ | `optimize_slow_query(connectionId, queryText, avgExecutionTimeMs?)` | AI optimization recommendations for a specific SQL — index suggestions, query rewrites, estimated impact. |
97
+ | `get_table_growth(connectionId, tableName?, days?)` | Persistent stats history: per-table size/row time series + headline rollups. Use to answer "which tables are growing fastest?" or "how much has X grown in the last month?" without scanning the live DB. |
98
+ | `get_growth_anomalies(connectionId, tableName?, unacknowledgedOnly?, days?)` | DeepSQL-flagged sudden growth spikes with severity (CRITICAL/WARNING/INFO), anomaly type, before/after sizes, confidence score. Check this BEFORE walking the user through a slow-query plan — a recent growth anomaly is often the real root cause. |
90
99
  | `execute_sql(connectionId, query, ...)` | Run any SQL — SELECT for everyone, DML/DDL for admins (two-step confirm). |
91
100
  | `analyze_query_plan(connectionId, query, useAnalyze=false)` | AI-enriched plan analysis (issues + index recs + summary). |
92
101
 
@@ -128,8 +137,9 @@ deepsql query "SELECT 1" --connection prod-pg --caller-agent claude-code --json
128
137
  | `deepsql relationships --connection <c>` | Inferred + validated FKs | `get_relationships` |
129
138
  | `deepsql anti-patterns --connection <c> [--kind table\|query]` | Anti-patterns | `get_anti_patterns` |
130
139
  | `deepsql digest [N]\|list\|show <id>` | **CLI-only**: daily digest of slow queries + AI commentary | none |
140
+ | `deepsql growth trends\|history\|anomalies\|ack\|capture\|config` | Table growth analytics (size/row trends, detected anomalies, alert thresholds) | partial: `get_table_growth`, `get_growth_anomalies` |
131
141
  | `deepsql indexes list\|missing\|health\|unused\|duplicates\|usage <table>` | **CLI-only**: index recommendations and usage stats | none |
132
- | `deepsql slow-queries latest\|history\|analyze\|optimize\|delete` | Slow-query analyses; `optimize` streams AI optimization steps live (SSE) | partial: `analyze_slow_queries` |
142
+ | `deepsql slow-queries latest\|history\|analyze\|optimize\|delete\|trends\|regressions\|timeline\|customers\|samples\|insights\|trigger` | Full slow-query toolkit. `optimize` streams AI optimization steps live (SSE); `customers` / `samples` / `insights` mirror the analytics MCP tools; `trigger` runs an immediate daily analysis. | mostly mirrored in MCP — use CLI for streaming optimize and one-shot triggers |
133
143
  | `deepsql users list\|get\|add\|set-role\|lock\|unlock\|disable\|resend-invite\|reset-password\|delete` | **Admin-only, CLI-only**: workspace user management | none |
134
144
  | `deepsql access list\|grant\|revoke\|policy <user> <conn>` | **Admin-only, CLI-only**: per-connection access grants + chat policy editing in $EDITOR | none |
135
145
  | `deepsql permissions list\|override\|reset` | **Admin-only, CLI-only**: role-based permission overrides | none |
@@ -147,12 +157,20 @@ shell-out (with `--caller-agent`) or tell the user the exact command:
147
157
  | User asks for | Run / suggest |
148
158
  |---|---|
149
159
  | "What changed in the database recently?" / "Today's report" | `deepsql digest` (most recent) or `deepsql digest 7` (last week) |
160
+ | "Which tables are growing fastest?" / "How big is `<table>` now vs a month ago?" | MCP: `get_table_growth(connectionId, days=30)`. CLI: `deepsql growth trends --connection <c>` (or `--table <name> --days 30`) |
161
+ | "Did any table spike in size recently?" / "Anything weird in growth?" | MCP: `get_growth_anomalies(connectionId, unacknowledgedOnly=true)`. CLI: `deepsql growth anomalies --connection <c> --unack` |
162
+ | "Force a fresh stats snapshot" (admin) | `deepsql growth capture --connection <c>` |
163
+ | "Set / view growth alert thresholds" (admin) | `deepsql growth config show --connection <c>` / `set --file <p>` |
150
164
  | "What indexes are we missing?" / "Index advice" | `deepsql indexes missing --connection <c>` |
151
165
  | "Are any indexes unused?" / "Index bloat" | `deepsql indexes unused --connection <c>` |
152
166
  | "Duplicate indexes?" | `deepsql indexes duplicates --connection <c>` |
153
167
  | "Index health on this connection" | `deepsql indexes health --connection <c>` |
154
168
  | "Indexes on `<table>` and how often they're used" | `deepsql indexes usage <table> --connection <c>` |
155
- | "Optimize this slow query with AI" / "Stream me a fix" | `deepsql slow-queries optimize --connection <c> --query-id <id>` |
169
+ | "Optimize this slow query with AI" / "Stream me a fix" | `deepsql slow-queries optimize --connection <c> --query-id <id>` (streamed) — or MCP `optimize_slow_query(connectionId, queryText)` for a one-shot JSON response |
170
+ | "Which customer is driving the most slow queries?" | MCP: `get_slow_query_customers(connectionId)`. CLI: `deepsql slow-queries customers --connection <c>` |
171
+ | "Show me the actual SQL run for this fingerprint" | MCP: `get_query_samples(connectionId, fingerprint)`. CLI: `deepsql slow-queries samples <fingerprint> --connection <c>` |
172
+ | "What slow-query insights / hotspots / tail-risk has DeepSQL found?" | MCP: `get_slow_query_insights(connectionId, kind?, window?)`. CLI: `deepsql slow-queries insights --connection <c> [--kind hotspots]` |
173
+ | "Trigger slow-query analysis now" / "Re-run the daily analysis" | CLI: `deepsql slow-queries trigger --connection <c>` |
156
174
  | "Add a new database connection" | `deepsql connections add` (interactive) or `--from-file <path>` |
157
175
  | "Test a connection without saving" | `deepsql connections test --from-file <path>` |
158
176
  | "Trigger brain re-initialization for this connection" | `deepsql connections init <name> --wait` |
package/src/auth/store.js CHANGED
@@ -30,18 +30,18 @@
30
30
 
31
31
  const fs = require("node:fs");
32
32
  const path = require("node:path");
33
- const os = require("node:os");
33
+ const { userHome } = require("../user-home");
34
34
 
35
35
  function configDir() {
36
36
  const override = process.env.DEEPSQL_CONFIG_DIR;
37
37
  if (override) return override;
38
38
  if (process.platform === "win32") {
39
- const base = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
39
+ const base = process.env.APPDATA || path.join(userHome(), "AppData", "Roaming");
40
40
  return path.join(base, "deepsql");
41
41
  }
42
42
  const xdg = process.env.XDG_CONFIG_HOME;
43
43
  if (xdg) return path.join(xdg, "deepsql");
44
- return path.join(os.homedir(), ".config", "deepsql");
44
+ return path.join(userHome(), ".config", "deepsql");
45
45
  }
46
46
 
47
47
  function authFilePath() {
package/src/cli.js CHANGED
@@ -31,6 +31,7 @@ const COMMANDS = {
31
31
  relationships: () => require("./commands/relationships"),
32
32
  "anti-patterns": () => require("./commands/anti-patterns"),
33
33
  digest: () => require("./commands/digest"),
34
+ growth: () => require("./commands/growth"),
34
35
  indexes: () => require("./commands/indexes"),
35
36
  users: () => require("./commands/users"),
36
37
  access: () => require("./commands/access"),
@@ -55,6 +56,7 @@ const COMMAND_LIST = [
55
56
  ["analyze", false, "AI-enriched query plan analysis (use --analyze for EXPLAIN ANALYZE)"],
56
57
  ["schema", false, "Dump connection schema or DB objects as JSON"],
57
58
  ["digest", true, "Show DeepSQL daily digests"],
59
+ ["growth", true, "Table growth analytics — trends, history, anomalies, alert config"],
58
60
  ["brain-context", false, "Retrieve embedding-ranked context for a question"],
59
61
  ["business-rules", false, "List active business rules and SQL guardrails"],
60
62
  ["relationships", false, "List inferred and validated FK relationships"],
@@ -224,6 +226,29 @@ const COMMAND_HELP = {
224
226
  ],
225
227
  },
226
228
 
229
+ growth: {
230
+ description: "Table growth analytics — size/row trends, detected anomalies, alert thresholds.",
231
+ usage: "deepsql growth <subcommand> [options]",
232
+ subcommands: [
233
+ ["trends [--table <t>] [--days N=30]", "Per-table growth headline over the last N days (default)"],
234
+ ["history [--table <t>] [--days N=7]", "Raw snapshot history rows"],
235
+ ["anomalies [--table <t>] [--unack] [--days N=30]", "Sudden growth spikes flagged by severity"],
236
+ ["ack <anomalyId>", "Acknowledge an anomaly"],
237
+ ["capture", "Trigger a fresh stats snapshot (admin; async)"],
238
+ ["config show [--table <t>]", "Show alert thresholds for the connection"],
239
+ ["config set --file <path>", "POST a GrowthAlertConfiguration JSON (admin)"],
240
+ ],
241
+ options: [
242
+ ["--connection <name>", "Connection to inspect"],
243
+ ["--table <name>", "Filter to one table"],
244
+ ["--days <N>", "Lookback window in days (max 365)"],
245
+ ["--unack", "anomalies: only unacknowledged ones"],
246
+ ["--file <path>", "config set: path to GrowthAlertConfiguration JSON"],
247
+ ["--json", "Raw JSON output"],
248
+ ],
249
+ notes: "`capture` and `config set` are the only mutations; both are admin-level but neither writes user data. Trends + anomalies are the agent-facing subcommands and are also exposed via the MCP tools `get_table_growth` and `get_growth_anomalies`.",
250
+ },
251
+
227
252
  "brain-context": {
228
253
  description: "Retrieve embedding-ranked tables/columns/FKs, training docs, and business rules for a question.",
229
254
  usage: 'deepsql brain-context "<question>" --connection <name> [options]',
@@ -565,6 +590,11 @@ function buildOpts(parsed) {
565
590
  // Indexes
566
591
  all: !!f.all,
567
592
  status: f.status || null,
593
+ // Growth analytics (also re-used wherever a per-table / lookback filter
594
+ // is useful — kept generic on purpose)
595
+ table: f.table || null,
596
+ days: f.days || null,
597
+ unack: !!f.unack,
568
598
  // mcp config installer
569
599
  install: !!f.install,
570
600
  print: !!f.print,
@@ -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 };