@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.
package/CLAUDE.md CHANGED
@@ -33,7 +33,7 @@ server, and the statement you ran.** Don't be sloppy.
33
33
 
34
34
  ## The tools you have
35
35
 
36
- The MCP server exposes 12 tools. They all take a `connectionId` (UUID
36
+ The MCP server exposes 14 tools. They all take a `connectionId` (UUID
37
37
  returned by `list_connections`) except `apply_index_recommendation`,
38
38
  which takes a server-resolved `recommendationId`.
39
39
 
@@ -47,6 +47,8 @@ which takes a server-resolved `recommendationId`.
47
47
  | `get_relationships` | Inferred + validated foreign keys with confidence scores. Many real DBs lack declared FKs; this fills the gap. |
48
48
  | `get_anti_patterns` | Schema-level (`kind=table`) or query-level (`kind=query`) anti-patterns. |
49
49
  | `analyze_slow_queries` | Recent slow queries with fingerprints, durations, examples. Read-only; doesn't trigger new work. |
50
+ | `get_slow_query_timeline` | Day-by-day timeline for one query from the 30-day analytics store — call count, mean/max time, regression factor per day. Identify the query by its fingerprint (the `queryId` from `analyze_slow_queries`). Answers "is this query getting slower". |
51
+ | `get_query_regressions` | Slow queries that regressed (got slower) on the latest daily analysis run, ranked by slowdown factor. Read-only. |
50
52
  | `get_index_recommendations` | **Workload-weighted DBA-grade index advisor.** Pre-computed top-N (default 5) recommendations ranked by net benefit (`Σ calls × mean_exec_time` − write-cost). Each result carries up to 5 contributing query fingerprints, the role each column played, and optional HypoPG cost-delta on Postgres. Covers both `CREATE_INDEX` and `DROP_INDEX` (unused + redundant-prefix) candidates. |
51
53
  | **`apply_index_recommendation`** | **The only write-capable MCP tool.** Apply (or dry-run) a recommendation against its target connection and measure the before/after benefit on contributing queries. `DRY_RUN` (default) uses HypoPG (Postgres-only) for zero-write cost-delta. `APPLY` runs real `CREATE/DROP INDEX CONCURRENTLY` (configurable via `concurrent`). `APPLY_AND_MEASURE` additionally runs `EXPLAIN ANALYZE` for wall-clock timings. Write modes require `confirm: true`. The DDL is server-generated from the recommendation row — clients never supply SQL. |
52
54
  | **`execute_sql`** | **Run any SQL statement.** Policy is server-enforced: developers can run SELECT/WITH/SHOW/EXPLAIN; admins can also run DML/DDL with a two-step confirmation. EXPLAIN and EXPLAIN ANALYZE are just SQL — no separate flag. |
@@ -211,9 +213,10 @@ for them to say yes, then re-call with `confirmMutation: true`.
211
213
 
212
214
  **"What indexes should we add?"** → `get_index_recommendations`. Returns
213
215
  the workload-weighted top-N (default 5) with net benefit, contributing
214
- queries, and HypoPG cost-delta when available. Use `deepsql indexes
215
- missing` / `deepsql indexes health` in the terminal for catalog-level
216
- counterparts (usage stats, duplicates).
216
+ queries, and HypoPG cost-delta when available. The terminal equivalent
217
+ is `deepsql indexes top` (same data, same ranking); `deepsql indexes
218
+ missing` / `health` / `unused` / `duplicates` cover the catalog-level
219
+ diagnostics that complement the workload-weighted advisor.
217
220
 
218
221
  **"How much faster will this index actually make things?"** →
219
222
  `apply_index_recommendation` with `mode: "DRY_RUN"` (default). On
@@ -225,7 +228,10 @@ the index (CONCURRENTLY) and run `EXPLAIN ANALYZE` before/after.
225
228
 
226
229
  **"What changed recently / what should I worry about?"** → Tell the user to
227
230
  run `deepsql digest` (today) or `deepsql digest 7` (last seven). The digest
228
- isn't MCP-exposed.
231
+ isn't MCP-exposed. The digest now includes a workload-weighted **Index Wins**
232
+ section that surfaces the same top-N recommendations `get_index_recommendations`
233
+ returns, with the `deepsql indexes apply <id> --mode dry-run` CTA — so a user
234
+ who skims the digest in Slack can flow directly into the apply path.
229
235
 
230
236
  **"Are there foreign keys between X and Y?"** → `get_relationships`. Many
231
237
  real-world DBs lack declared FKs but DeepSQL's brain infers them; check the
@@ -318,11 +324,16 @@ them at the terminal command rather than trying to fake it through
318
324
 
319
325
  | Capability | CLI command |
320
326
  |---|---|
321
- | Catalog-level index stats (counterpart to `get_index_recommendations`) | `deepsql indexes list`, `deepsql indexes missing` |
322
- | Unused / duplicate index detection | `deepsql indexes unused`, `deepsql indexes duplicates` |
323
- | Per-table index usage stats | `deepsql indexes usage <table>` |
324
- | Index health report | `deepsql indexes health` |
325
- | Workload-weighted advisor + apply (DBA-grade) | `deepsql index-recommendations top` / `apply <id>` — same surface as the MCP `get_index_recommendations` + `apply_index_recommendation` tools, useful from a terminal |
327
+ | Workload-weighted advisor (terminal mirror of `get_index_recommendations`) | `deepsql indexes top [--limit N]` |
328
+ | Apply / dry-run an advisor recommendation (terminal mirror of `apply_index_recommendation`) | `deepsql indexes apply <id> [--mode dry-run\|apply\|apply-and-measure] [--confirm]` |
329
+ | Force a fresh accumulation cycle | `deepsql indexes refresh` |
330
+ | Full recommendation detail incl. contributing queries | `deepsql indexes show <id>` |
331
+ | Dismiss a recommendation | `deepsql indexes dismiss <id>` |
332
+ | Browse all recommendations (any status) | `deepsql indexes list [--all\|--status …]` |
333
+ | Catalog: missing-index suggestions | `deepsql indexes missing` |
334
+ | Catalog: unused / duplicate index detection | `deepsql indexes unused`, `deepsql indexes duplicates` |
335
+ | Catalog: per-table index usage stats | `deepsql indexes usage <table>` |
336
+ | Catalog: index health report | `deepsql indexes health` |
326
337
  | Daily digest (anomalies + AI commentary) | `deepsql digest`, `deepsql digest 7` |
327
338
  | Streaming AI optimization for a slow query | `deepsql slow-queries optimize --query-id <id>` |
328
339
 
@@ -217,6 +217,107 @@ const TOOL_DEFINITIONS = [
217
217
  additionalProperties: false,
218
218
  },
219
219
  },
220
+ {
221
+ name: "get_slow_query_timeline",
222
+ description:
223
+ "Get the day-by-day timeline for one slow query from DeepSQL's 30-day analytics store. "
224
+ + "The query is identified by its stable fingerprint (the MD5 of the normalized query — "
225
+ + "the `queryId` field returned by analyze_slow_queries). Returns one point per day with "
226
+ + "call count, mean/max execution time, and the regression factor versus the previous day. "
227
+ + "Use this to answer 'is this query getting slower over time'.",
228
+ inputSchema: {
229
+ type: "object",
230
+ properties: {
231
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
232
+ fingerprint: {
233
+ type: "string",
234
+ description: "Stable query fingerprint (MD5 of the normalized query).",
235
+ },
236
+ },
237
+ required: ["connectionId", "fingerprint"],
238
+ additionalProperties: false,
239
+ },
240
+ },
241
+ {
242
+ name: "get_query_regressions",
243
+ description:
244
+ "List slow queries that regressed (got slower) on the most recent daily analysis run. "
245
+ + "Each result carries the fingerprint, normalized SQL, current mean execution time, and "
246
+ + "the regression factor (this period's mean / the previous period's mean). Read-only.",
247
+ inputSchema: {
248
+ type: "object",
249
+ properties: {
250
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
251
+ minFactor: {
252
+ type: "number",
253
+ minimum: 1,
254
+ description: "Minimum slowdown multiple to report. Defaults to 1.5 (≥50% slower).",
255
+ },
256
+ },
257
+ required: ["connectionId"],
258
+ additionalProperties: false,
259
+ },
260
+ },
261
+ {
262
+ name: "get_table_growth",
263
+ description:
264
+ "Get table size / row-count growth trends for a connection from DeepSQL's persistent stats history. "
265
+ + "Returns three parallel time series (sizeOverTime, growthOverTime, rowCountOverTime) plus per-table "
266
+ + "headline rollups suitable for answering questions like \"which tables are growing fastest?\" or "
267
+ + "\"how much has `orders` grown in the last month?\". Backed by snapshots stored in DeepSQL's "
268
+ + "`table_stats_history`, not live `pg_total_relation_size()` probes — so it can show growth velocity "
269
+ + "and bloat over time without re-scanning the customer's database.",
270
+ inputSchema: {
271
+ type: "object",
272
+ properties: {
273
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
274
+ tableName: {
275
+ type: "string",
276
+ description: "Optional table name to scope the trends to a single table. Omit for all tables.",
277
+ },
278
+ days: {
279
+ type: "integer",
280
+ minimum: 1,
281
+ maximum: 365,
282
+ description: "Lookback window in days. Defaults to 30.",
283
+ },
284
+ },
285
+ required: ["connectionId"],
286
+ additionalProperties: false,
287
+ },
288
+ },
289
+ {
290
+ name: "get_growth_anomalies",
291
+ description:
292
+ "Get growth anomalies DeepSQL flagged on a connection — sudden size or row spikes that exceeded the "
293
+ + "configured thresholds (percentage growth, absolute byte growth, statistical z-score). Each anomaly "
294
+ + "carries severity (CRITICAL / WARNING / INFO), the before/after sizes, an anomaly type "
295
+ + "(PERCENTAGE_GROWTH, ABSOLUTE_GROWTH, STATISTICAL_ANOMALY, ROW_SPIKE, NEW_TABLE), a human-readable "
296
+ + "description, and a confidence score. Use this BEFORE walking the user through a plan to optimize a "
297
+ + "table — the anomaly may be the root cause they should investigate first.",
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: {
301
+ connectionId: { type: "string", description: "DeepSQL connection ID." },
302
+ tableName: {
303
+ type: "string",
304
+ description: "Optional table name to scope to one table. Omit for the whole connection.",
305
+ },
306
+ unacknowledgedOnly: {
307
+ type: "boolean",
308
+ description: "When true, only return anomalies the operator hasn't acked yet. Defaults to false.",
309
+ },
310
+ days: {
311
+ type: "integer",
312
+ minimum: 1,
313
+ maximum: 365,
314
+ description: "Lookback window in days. Defaults to 30.",
315
+ },
316
+ },
317
+ required: ["connectionId"],
318
+ additionalProperties: false,
319
+ },
320
+ },
220
321
  {
221
322
  name: "execute_sql",
222
323
  description:
@@ -740,6 +841,90 @@ function summarizeSlowQueries(payload) {
740
841
  + parts.join("");
741
842
  }
742
843
 
844
+ function summarizeTableGrowth(payload) {
845
+ // Backend returns { success, trends: { sizeOverTime[], growthOverTime[],
846
+ // rowCountOverTime[] }, days }. We don't want to dump the raw arrays into
847
+ // the agent's context — collapse to a per-table headline and a top-3
848
+ // "growing fastest" list.
849
+ const trends = payload?.trends || {};
850
+ const sizeOverTime = Array.isArray(trends.sizeOverTime) ? trends.sizeOverTime : [];
851
+ if (sizeOverTime.length === 0) {
852
+ return "No table-growth history for this connection in the requested window. "
853
+ + "The customer may not have stats-snapshot collection enabled yet.";
854
+ }
855
+
856
+ // Roll up per-table: first vs last snapshot.
857
+ const byTable = new Map();
858
+ for (const point of sizeOverTime) {
859
+ const t = point.table || "(unknown)";
860
+ if (!byTable.has(t)) byTable.set(t, []);
861
+ byTable.get(t).push(point);
862
+ }
863
+ const rows = [];
864
+ for (const [table, points] of byTable.entries()) {
865
+ points.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
866
+ const first = points[0];
867
+ const last = points[points.length - 1];
868
+ const firstBytes = first?.sizeBytes ?? 0;
869
+ const lastBytes = last?.sizeBytes ?? 0;
870
+ const deltaBytes = lastBytes - firstBytes;
871
+ rows.push({ table, firstBytes, lastBytes, deltaBytes });
872
+ }
873
+ rows.sort((a, b) => Math.abs(b.deltaBytes) - Math.abs(a.deltaBytes));
874
+
875
+ const days = payload?.days != null ? `${payload.days}d` : "window";
876
+ const top = rows.slice(0, 3).map((r) => {
877
+ const arrow = r.deltaBytes >= 0 ? "↑" : "↓";
878
+ const pct = r.firstBytes > 0
879
+ ? ` (${r.deltaBytes >= 0 ? "+" : ""}${((r.deltaBytes / r.firstBytes) * 100).toFixed(1)}%)`
880
+ : "";
881
+ return `${r.table} ${arrow} ${formatBytesHumanLib(Math.abs(r.deltaBytes))}${pct}`;
882
+ });
883
+ return `${rows.length} table(s) with growth data over ${days}. `
884
+ + `Most-changed: ${top.join("; ")}.`;
885
+ }
886
+
887
+ function summarizeGrowthAnomalies(payload) {
888
+ // Backend returns { success, anomalies[], statistics: { total, warning,
889
+ // critical, info, acknowledged, unacknowledged } }. Agents should know
890
+ // BEFORE drilling into a slow query whether a recent growth anomaly is
891
+ // the real root cause.
892
+ const list = Array.isArray(payload?.anomalies) ? payload.anomalies : [];
893
+ if (list.length === 0) {
894
+ return "No growth anomalies detected in the requested window.";
895
+ }
896
+ const stats = payload?.statistics || {};
897
+ const total = stats.total ?? list.length;
898
+ const crit = stats.critical ?? 0;
899
+ const warn = stats.warning ?? 0;
900
+ const unack = stats.unacknowledged ?? 0;
901
+
902
+ // Surface the worst recent one so the agent has something concrete to
903
+ // reference without having to walk the structured payload.
904
+ const worst = list.find((a) => a && a.severity === "CRITICAL")
905
+ || list.find((a) => a && a.severity === "WARNING")
906
+ || list[0];
907
+ const worstLine = worst
908
+ ? ` Top: [${worst.severity || "INFO"}] ${worst.tableName || "?"} — `
909
+ + `${worst.anomalyType || "growth"}`
910
+ + (worst.sizeGrowthPercent != null
911
+ ? ` ${worst.sizeGrowthPercent > 0 ? "+" : ""}${worst.sizeGrowthPercent.toFixed(1)}%`
912
+ : "")
913
+ : "";
914
+ return `${total} growth anomal${total === 1 ? "y" : "ies"} `
915
+ + `(${crit} critical, ${warn} warning, ${unack} unacknowledged).${worstLine}`;
916
+ }
917
+
918
+ function formatBytesHumanLib(bytes) {
919
+ if (bytes == null) return "?";
920
+ const abs = Math.abs(bytes);
921
+ if (abs < 1024) return `${bytes} B`;
922
+ if (abs < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
923
+ if (abs < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
924
+ if (abs < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
925
+ return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
926
+ }
927
+
743
928
  function summarizeQueryResult(payload) {
744
929
  const result = payload?.result || payload?.data || payload;
745
930
  const rowCount = result?.rowCount ?? 0;
@@ -785,6 +970,12 @@ function buildToolResult(name, payload, extra = {}) {
785
970
  case "analyze_slow_queries":
786
971
  summary = summarizeSlowQueries(payload);
787
972
  break;
973
+ case "get_table_growth":
974
+ summary = summarizeTableGrowth(payload);
975
+ break;
976
+ case "get_growth_anomalies":
977
+ summary = summarizeGrowthAnomalies(payload);
978
+ break;
788
979
  case "get_index_recommendations":
789
980
  summary = summarizeIndexRecommendations(payload);
790
981
  break;
@@ -969,6 +1160,64 @@ async function handleToolCall(config, name, args = {}) {
969
1160
  return buildToolResult(name, payload);
970
1161
  }
971
1162
 
1163
+ case "get_slow_query_timeline": {
1164
+ const connectionId = String(args.connectionId || "").trim();
1165
+ if (!connectionId) return buildToolError("connectionId is required.");
1166
+ const fingerprint = String(args.fingerprint || "").trim();
1167
+ if (!fingerprint) return buildToolError("fingerprint is required.");
1168
+ const payload = await callDeepSqlApi(
1169
+ config,
1170
+ `/slow-query-analytics/${encodeURIComponent(connectionId)}/timeline/${encodeURIComponent(fingerprint)}`,
1171
+ );
1172
+ return buildToolResult(name, payload);
1173
+ }
1174
+
1175
+ case "get_query_regressions": {
1176
+ const connectionId = String(args.connectionId || "").trim();
1177
+ if (!connectionId) return buildToolError("connectionId is required.");
1178
+ const minFactor = args.minFactor != null ? Number(args.minFactor) : 1.5;
1179
+ const payload = await callDeepSqlApi(
1180
+ config,
1181
+ `/slow-query-analytics/${encodeURIComponent(connectionId)}/regressions?minFactor=${minFactor}`,
1182
+ );
1183
+ return buildToolResult(name, payload);
1184
+ }
1185
+
1186
+ case "get_table_growth": {
1187
+ const connectionId = String(args.connectionId || "").trim();
1188
+ if (!connectionId) return buildToolError("connectionId is required.");
1189
+ const params = [];
1190
+ const days = clampInteger(args.days, 1, 365, 30);
1191
+ params.push(`days=${days}`);
1192
+ if (args.tableName) {
1193
+ params.push(`tableName=${encodeURIComponent(String(args.tableName))}`);
1194
+ }
1195
+ const payload = await callDeepSqlApi(
1196
+ config,
1197
+ `/growth-monitoring/trends/${encodeURIComponent(connectionId)}?${params.join("&")}`,
1198
+ );
1199
+ return buildToolResult(name, payload);
1200
+ }
1201
+
1202
+ case "get_growth_anomalies": {
1203
+ const connectionId = String(args.connectionId || "").trim();
1204
+ if (!connectionId) return buildToolError("connectionId is required.");
1205
+ const params = [];
1206
+ const days = clampInteger(args.days, 1, 365, 30);
1207
+ params.push(`days=${days}`);
1208
+ if (args.tableName) {
1209
+ params.push(`tableName=${encodeURIComponent(String(args.tableName))}`);
1210
+ }
1211
+ if (args.unacknowledgedOnly === true) {
1212
+ params.push("unacknowledgedOnly=true");
1213
+ }
1214
+ const payload = await callDeepSqlApi(
1215
+ config,
1216
+ `/growth-monitoring/anomalies/${encodeURIComponent(connectionId)}?${params.join("&")}`,
1217
+ );
1218
+ return buildToolResult(name, payload);
1219
+ }
1220
+
972
1221
  case "execute_sql": {
973
1222
  const connectionId = String(args.connectionId || "").trim();
974
1223
  const query = String(args.query || "").trim();
@@ -1075,6 +1324,8 @@ module.exports = {
1075
1324
  stripSqlComments,
1076
1325
  stripSqlStringLiterals,
1077
1326
  summarizeApplyResult,
1327
+ summarizeGrowthAnomalies,
1078
1328
  summarizeIndexRecommendations,
1329
+ summarizeTableGrowth,
1079
1330
  validateReadOnlySql,
1080
1331
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepsql/mcp",
3
- "version": "0.14.0",
3
+ "version": "0.17.0",
4
4
  "description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
5
5
  "bin": {
6
6
  "deepsql": "bin/deepsql.js",
@@ -87,6 +87,8 @@ 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_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. |
91
+ | `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
92
  | `execute_sql(connectionId, query, ...)` | Run any SQL — SELECT for everyone, DML/DDL for admins (two-step confirm). |
91
93
  | `analyze_query_plan(connectionId, query, useAnalyze=false)` | AI-enriched plan analysis (issues + index recs + summary). |
92
94
 
@@ -128,6 +130,7 @@ deepsql query "SELECT 1" --connection prod-pg --caller-agent claude-code --json
128
130
  | `deepsql relationships --connection <c>` | Inferred + validated FKs | `get_relationships` |
129
131
  | `deepsql anti-patterns --connection <c> [--kind table\|query]` | Anti-patterns | `get_anti_patterns` |
130
132
  | `deepsql digest [N]\|list\|show <id>` | **CLI-only**: daily digest of slow queries + AI commentary | none |
133
+ | `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
134
  | `deepsql indexes list\|missing\|health\|unused\|duplicates\|usage <table>` | **CLI-only**: index recommendations and usage stats | none |
132
135
  | `deepsql slow-queries latest\|history\|analyze\|optimize\|delete` | Slow-query analyses; `optimize` streams AI optimization steps live (SSE) | partial: `analyze_slow_queries` |
133
136
  | `deepsql users list\|get\|add\|set-role\|lock\|unlock\|disable\|resend-invite\|reset-password\|delete` | **Admin-only, CLI-only**: workspace user management | none |
@@ -147,6 +150,10 @@ shell-out (with `--caller-agent`) or tell the user the exact command:
147
150
  | User asks for | Run / suggest |
148
151
  |---|---|
149
152
  | "What changed in the database recently?" / "Today's report" | `deepsql digest` (most recent) or `deepsql digest 7` (last week) |
153
+ | "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`) |
154
+ | "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` |
155
+ | "Force a fresh stats snapshot" (admin) | `deepsql growth capture --connection <c>` |
156
+ | "Set / view growth alert thresholds" (admin) | `deepsql growth config show --connection <c>` / `set --file <p>` |
150
157
  | "What indexes are we missing?" / "Index advice" | `deepsql indexes missing --connection <c>` |
151
158
  | "Are any indexes unused?" / "Index bloat" | `deepsql indexes unused --connection <c>` |
152
159
  | "Duplicate indexes?" | `deepsql indexes duplicates --connection <c>` |
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,12 +31,12 @@ 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"),
37
38
  permissions: () => require("./commands/permissions"),
38
39
  "slow-queries": () => require("./commands/slow-queries"),
39
- "index-recommendations": () => require("./commands/index-recommendations"),
40
40
  setup: () => require("./commands/setup"),
41
41
  };
42
42
 
@@ -56,12 +56,12 @@ const COMMAND_LIST = [
56
56
  ["analyze", false, "AI-enriched query plan analysis (use --analyze for EXPLAIN ANALYZE)"],
57
57
  ["schema", false, "Dump connection schema or DB objects as JSON"],
58
58
  ["digest", true, "Show DeepSQL daily digests"],
59
+ ["growth", true, "Table growth analytics — trends, history, anomalies, alert config"],
59
60
  ["brain-context", false, "Retrieve embedding-ranked context for a question"],
60
61
  ["business-rules", false, "List active business rules and SQL guardrails"],
61
62
  ["relationships", false, "List inferred and validated FK relationships"],
62
63
  ["anti-patterns", false, "List schema- or query-level anti-patterns"],
63
- ["indexes", true, "Index suggestions, usage, and health (read-only)"],
64
- ["index-recommendations", true, "Workload-weighted index advisor + HypoPG-validated apply tool"],
64
+ ["indexes", true, "Workload-weighted index advisor + catalog diagnostics (top, apply, list, missing, health, unused, duplicates, usage)"],
65
65
  ["users", true, "Manage workspace users (admin)"],
66
66
  ["access", true, "Manage per-connection access grants (admin)"],
67
67
  ["permissions", true, "Manage role-based permission overrides (admin)"],
@@ -226,6 +226,29 @@ const COMMAND_HELP = {
226
226
  ],
227
227
  },
228
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
+
229
252
  "brain-context": {
230
253
  description: "Retrieve embedding-ranked tables/columns/FKs, training docs, and business rules for a question.",
231
254
  usage: 'deepsql brain-context "<question>" --connection <name> [options]',
@@ -267,23 +290,41 @@ const COMMAND_HELP = {
267
290
  },
268
291
 
269
292
  indexes: {
270
- description: "Index suggestions, usage, and health — read-only in V1.",
293
+ description:
294
+ "Unified index advisor — workload-weighted recommendations + catalog diagnostics. " +
295
+ "`apply` is the one mutation; dry-run is the default and uses HypoPG on Postgres " +
296
+ "to estimate cost-delta without writes. Write modes (apply / apply-and-measure) " +
297
+ "require --confirm.",
271
298
  usage: "deepsql indexes <subcommand> [options]",
272
299
  subcommands: [
273
- ["list [--all] [--status <s>]", "Index recommendations (defaults to PENDING)"],
274
- ["missing", "Missing-index suggestions from the advisor"],
275
- ["health", "Comprehensive index health report"],
276
- ["unused", "Indexes the engine has not used"],
277
- ["duplicates", "Duplicate or redundant indexes"],
278
- ["usage <table>", "Per-table index usage statistics"],
300
+ // Advisor (workload-weighted)
301
+ ["top [--limit N]", "Pre-computed top-N pending recommendations (default 5, max 50) — ranked by net benefit, with HypoPG cost-delta + contributing-query evidence"],
302
+ ["list [--all] [--status PENDING|APPLIED|DISMISSED]", "All recommendations (defaults to PENDING) — simpler view than `top`"],
303
+ ["show <id> --connection <name>", "Full detail: workload, write-cost, HypoPG cost, contributing queries"],
304
+ ["refresh", "Force a fresh accumulation cycle (POST /generate)"],
305
+ ["apply <id> [--mode <m>] [--confirm] [--no-concurrent]", "Run or dry-run a recommendation with before/after measurement"],
306
+ ["dismiss <id>", "Mark a recommendation as DISMISSED"],
307
+ // Catalog diagnostics
308
+ ["missing", "Missing-index suggestions from the catalog advisor"],
309
+ ["health", "Comprehensive index health report"],
310
+ ["unused", "Indexes the engine has not used (live pg_stat_user_indexes / sys.* probe)"],
311
+ ["duplicates", "Duplicate or redundant indexes"],
312
+ ["usage <table>", "Per-table index usage statistics"],
279
313
  ],
280
314
  options: [
281
- ["--connection <name>", "Connection to inspect"],
282
- ["--all", "Include APPLIED and DISMISSED in `list`"],
283
- ["--status PENDING|APPLIED|DISMISSED", "Filter `list` by status"],
284
- ["--json", "Raw JSON output"],
315
+ ["--connection <name>", "Connection to inspect"],
316
+ ["--limit <n>", "top: number of recommendations (default 5, max 50)"],
317
+ ["--all", "list: include APPLIED and DISMISSED rows"],
318
+ ["--status PENDING|APPLIED|DISMISSED", "list: filter by status"],
319
+ ["--mode dry-run|apply|apply-and-measure", "apply: defaults to dry-run (HypoPG; no writes)"],
320
+ ["--confirm", "apply: required for apply / apply-and-measure (write modes)"],
321
+ ["--no-concurrent", "apply: skip CREATE/DROP INDEX CONCURRENTLY (small dev tables)"],
322
+ ["--json", "Raw backend JSON instead of the terminal-friendly table"],
285
323
  ],
286
- notes: "V1 is read-only. Apply, dismiss, delete, and generate are not exposed by the CLI yet.",
324
+ notes:
325
+ "Mirrors the MCP `get_index_recommendations` + `apply_index_recommendation` tools — " +
326
+ "use this when you're at a terminal, the MCP tools when you're inside an AI client. " +
327
+ "Same backend, same data, same safety contract.",
287
328
  },
288
329
 
289
330
  users: {
@@ -363,33 +404,6 @@ const COMMAND_HELP = {
363
404
  notes: "Org and LLM config are set at install time and are NOT touched by this wizard.",
364
405
  },
365
406
 
366
- "index-recommendations": {
367
- description:
368
- "DBA-grade workload-weighted index advisor. Default `top` shows pending " +
369
- "recommendations ranked by net benefit (workload − write-cost). `apply` " +
370
- "is the only mutation — dry-run uses HypoPG (Postgres-only) so no writes " +
371
- "leak; the two write modes require --confirm.",
372
- usage:
373
- "deepsql index-recommendations <top|list|show|refresh|apply|dismiss> [options]",
374
- subcommands: [
375
- ["top --connection <name> [--limit N]", "Pre-computed top-N (default 5, max 50), evidence-bearing"],
376
- ["list --connection <name>", "All pending recommendations for the connection"],
377
- ["show <id> --connection <name>", "Full detail: workload, HypoPG cost, contributing queries"],
378
- ["refresh --connection <name>", "Force a fresh accumulation cycle (POST /generate)"],
379
- ["apply <id> [--mode <m>] [--confirm]", "Run / dry-run with before/after measurement"],
380
- ["dismiss <id>", "Mark a recommendation as DISMISSED"],
381
- ],
382
- options: [
383
- ["--limit <n>", "top: number of recommendations (default 5, max 50)"],
384
- ["--mode <m>", "apply: dry-run (default) | apply | apply-and-measure"],
385
- ["--confirm", "apply: required for apply / apply-and-measure (write modes)"],
386
- ["--no-concurrent", "apply: skip CREATE/DROP INDEX CONCURRENTLY (dev / small tables)"],
387
- ["--json", "Emit raw backend JSON instead of the terminal-friendly table"],
388
- ],
389
- notes:
390
- "Complements `deepsql indexes` (catalog-level usage / health). " +
391
- "This namespace surfaces the workload-weighted advisor + the apply tool.",
392
- },
393
407
  };
394
408
 
395
409
  // ─── Color helpers ──────────────────────────────────────────────────────────
@@ -576,6 +590,11 @@ function buildOpts(parsed) {
576
590
  // Indexes
577
591
  all: !!f.all,
578
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,
579
598
  // mcp config installer
580
599
  install: !!f.install,
581
600
  print: !!f.print,