@indiekitai/pg-dash 0.5.2 → 0.7.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/README.md +25 -3
- package/README.zh-CN.md +10 -3
- package/dist/cli.js +787 -1
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +692 -0
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1275,6 +1275,594 @@ var init_snapshot = __esm({
|
|
|
1275
1275
|
}
|
|
1276
1276
|
});
|
|
1277
1277
|
|
|
1278
|
+
// src/server/queries/db-context.ts
|
|
1279
|
+
function inferBusinessIntent(tableName, columnNames) {
|
|
1280
|
+
const name = tableName.toLowerCase();
|
|
1281
|
+
const cols = columnNames.map((c) => c.toLowerCase());
|
|
1282
|
+
const patterns = [
|
|
1283
|
+
[/^(user|users?|account|accounts?|customer|customers?|member|members?)$/, "\u7528\u6237/\u4F1A\u5458\u7BA1\u7406"],
|
|
1284
|
+
[/^(order|orders?|purchase|purchases?)$/, "\u8BA2\u5355/\u8D2D\u4E70\u8BB0\u5F55"],
|
|
1285
|
+
[/^(product|products?|item|items?|goods?)$/, "\u5546\u54C1/\u4EA7\u54C1\u76EE\u5F55"],
|
|
1286
|
+
[/^(payment|payments?|transaction|transactions?|invoice|invoices?)$/, "\u652F\u4ED8/\u4EA4\u6613\u8BB0\u5F55"],
|
|
1287
|
+
[/^(session|sessions?|auth|authentication|token|tokens?)$/, "\u8BA4\u8BC1/\u4F1A\u8BDD\u7BA1\u7406"],
|
|
1288
|
+
[/^(log|logs?|audit|audits?|history|histories?)$/, "\u65E5\u5FD7/\u5BA1\u8BA1\u8BB0\u5F55"],
|
|
1289
|
+
[/^(config|configuration|settings?)$/, "\u914D\u7F6E/\u8BBE\u7F6E"],
|
|
1290
|
+
[/^(category|categories?|tag|tags?|group|groups?)$/, "\u5206\u7C7B/\u6807\u7B7E/\u5206\u7EC4"],
|
|
1291
|
+
[/^(comment|comments?|review|reviews?|feedback)$/, "\u8BC4\u8BBA/\u53CD\u9988"],
|
|
1292
|
+
[/^(notification|notifications?|message|messages?)$/, "\u901A\u77E5/\u6D88\u606F"],
|
|
1293
|
+
[/^(file|files?|attachment|attachments?|media)$/, "\u6587\u4EF6/\u5A92\u4F53"],
|
|
1294
|
+
[/^(api[_-]?key|api[_-]?key|key|keys?|credential|credentials?)$/, "API \u5BC6\u94A5/\u51ED\u8BC1"],
|
|
1295
|
+
[/^(job|jobs?|queue|queues?|task|tasks?)$/, "\u4EFB\u52A1/\u961F\u5217"],
|
|
1296
|
+
[/^(subscription|subscriptions?|plan|plans?)$/, "\u8BA2\u9605/\u5957\u9910"],
|
|
1297
|
+
[/^(coupon|coupons?|promo|promotion|promotions?)$/, "\u4F18\u60E0/\u4FC3\u9500"],
|
|
1298
|
+
[/^(analytics?|statistic|statistics?|metric|metrics?)$/, "\u5206\u6790/\u7EDF\u8BA1"]
|
|
1299
|
+
];
|
|
1300
|
+
for (const [pattern, intent] of patterns) {
|
|
1301
|
+
if (pattern.test(name)) return intent;
|
|
1302
|
+
}
|
|
1303
|
+
const colPatterns = [
|
|
1304
|
+
[/user_id|customer_id|member_id/, "\u7528\u6237\u5173\u8054"],
|
|
1305
|
+
[/order_id|purchase_id/, "\u8BA2\u5355\u5173\u8054"],
|
|
1306
|
+
[/product_id|item_id/, "\u5546\u54C1\u5173\u8054"],
|
|
1307
|
+
[/status|state/, "\u72B6\u6001\u7BA1\u7406"],
|
|
1308
|
+
[/created_at|updated_at|deleted_at/, "\u65F6\u95F4\u6233/\u8F6F\u5220\u9664"],
|
|
1309
|
+
[/email|phone|address/, "\u8054\u7CFB\u4FE1\u606F"],
|
|
1310
|
+
[/price|amount|total|cost/, "\u91D1\u989D/\u4EF7\u683C"],
|
|
1311
|
+
[/quantity|count|qty/, "\u6570\u91CF"],
|
|
1312
|
+
[/latitude|longitude|location/, "\u5730\u7406\u4F4D\u7F6E"],
|
|
1313
|
+
[/ip|user_agent|browser/, "\u8BBF\u95EE\u4FE1\u606F"]
|
|
1314
|
+
];
|
|
1315
|
+
const matchedColHints = colPatterns.filter(
|
|
1316
|
+
([pattern]) => cols.some((col) => pattern.test(col))
|
|
1317
|
+
).map(([, hint]) => hint);
|
|
1318
|
+
if (matchedColHints.length > 0) {
|
|
1319
|
+
return `\u6570\u636E\u8868 (\u53EF\u80FD\u7528\u9014: ${matchedColHints.slice(0, 2).join("\u3001")})`;
|
|
1320
|
+
}
|
|
1321
|
+
return "\u901A\u7528\u6570\u636E\u8868";
|
|
1322
|
+
}
|
|
1323
|
+
async function getDbContext(pool) {
|
|
1324
|
+
const client = await pool.connect();
|
|
1325
|
+
try {
|
|
1326
|
+
const tablesResult = await client.query(`
|
|
1327
|
+
SELECT
|
|
1328
|
+
n.nspname AS schema,
|
|
1329
|
+
c.relname AS table_name,
|
|
1330
|
+
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
|
|
1331
|
+
pg_total_relation_size(c.oid) AS total_size_bytes,
|
|
1332
|
+
pg_relation_size(c.oid) AS table_size_bytes,
|
|
1333
|
+
pg_indexes_size(c.oid) AS index_size_bytes,
|
|
1334
|
+
s.n_live_tup AS row_count,
|
|
1335
|
+
s.n_dead_tup AS dead_tuples,
|
|
1336
|
+
obj_description(c.oid) AS description
|
|
1337
|
+
FROM pg_class c
|
|
1338
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
1339
|
+
LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
|
|
1340
|
+
WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
1341
|
+
ORDER BY pg_total_relation_size(c.oid) DESC
|
|
1342
|
+
`);
|
|
1343
|
+
const columnsResult = await client.query(`
|
|
1344
|
+
SELECT
|
|
1345
|
+
n.nspname AS schema,
|
|
1346
|
+
c.relname AS table_name,
|
|
1347
|
+
a.attname AS column_name,
|
|
1348
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
|
|
1349
|
+
NOT a.attnotnull AS is_nullable,
|
|
1350
|
+
pg_get_expr(d.adbin, d.adrelid) AS default_value,
|
|
1351
|
+
col_description(a.attrelid, a.attnum) AS description,
|
|
1352
|
+
a.attnum AS ordinal_position
|
|
1353
|
+
FROM pg_attribute a
|
|
1354
|
+
JOIN pg_class c ON a.attrelid = c.oid
|
|
1355
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
1356
|
+
LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
|
1357
|
+
WHERE a.attnum > 0 AND NOT a.attisdropped
|
|
1358
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
1359
|
+
ORDER BY n.nspname, c.relname, a.attnum
|
|
1360
|
+
`);
|
|
1361
|
+
const pkResult = await client.query(`
|
|
1362
|
+
SELECT
|
|
1363
|
+
n.nspname AS schema,
|
|
1364
|
+
c.relname AS table_name,
|
|
1365
|
+
a.attname AS column_name
|
|
1366
|
+
FROM pg_index idx
|
|
1367
|
+
JOIN pg_class c ON idx.indrelid = c.oid
|
|
1368
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
1369
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(idx.indkey)
|
|
1370
|
+
WHERE idx.indisprimary = true
|
|
1371
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
1372
|
+
ORDER BY n.nspname, c.relname, a.attnum
|
|
1373
|
+
`);
|
|
1374
|
+
const fkResult = await client.query(`
|
|
1375
|
+
SELECT
|
|
1376
|
+
n.nspname AS schema,
|
|
1377
|
+
c.relname AS table_name,
|
|
1378
|
+
a.attname AS column_name,
|
|
1379
|
+
ref_n.nspname AS referenced_schema,
|
|
1380
|
+
ref_c.relname AS referenced_table,
|
|
1381
|
+
ref_a.attname AS referenced_column,
|
|
1382
|
+
conname AS constraint_name
|
|
1383
|
+
FROM pg_constraint con
|
|
1384
|
+
JOIN pg_class c ON con.conrelid = c.oid
|
|
1385
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
1386
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(con.conkey)
|
|
1387
|
+
JOIN pg_class ref_c ON con.confrelid = ref_c.oid
|
|
1388
|
+
JOIN pg_namespace ref_n ON ref_c.relnamespace = ref_n.oid
|
|
1389
|
+
JOIN pg_attribute ref_a ON ref_a.attrelid = ref_c.oid AND ref_a.attnum = ANY(con.confkey)
|
|
1390
|
+
WHERE con.contype = 'f'
|
|
1391
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
1392
|
+
ORDER BY n.nspname, c.relname, con.conname
|
|
1393
|
+
`);
|
|
1394
|
+
const indexesResult = await client.query(`
|
|
1395
|
+
SELECT
|
|
1396
|
+
n.nspname AS schema,
|
|
1397
|
+
t.relname AS table_name,
|
|
1398
|
+
i.relname AS index_name,
|
|
1399
|
+
am.amname AS index_type,
|
|
1400
|
+
pg_get_indexdef(idx.indexrelid) AS definition,
|
|
1401
|
+
idx.indisunique AS is_unique,
|
|
1402
|
+
idx.indisprimary AS is_primary,
|
|
1403
|
+
pg_relation_size(i.oid) AS size_bytes
|
|
1404
|
+
FROM pg_index idx
|
|
1405
|
+
JOIN pg_class i ON idx.indexrelid = i.oid
|
|
1406
|
+
JOIN pg_class t ON idx.indrelid = t.oid
|
|
1407
|
+
JOIN pg_namespace n ON t.relnamespace = n.oid
|
|
1408
|
+
JOIN pg_am am ON i.relam = am.oid
|
|
1409
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
1410
|
+
ORDER BY t.relname, i.relname
|
|
1411
|
+
`);
|
|
1412
|
+
const tables = tablesResult.rows;
|
|
1413
|
+
const allColumns = columnsResult.rows;
|
|
1414
|
+
const primaryKeys = pkResult.rows;
|
|
1415
|
+
const foreignKeys = fkResult.rows;
|
|
1416
|
+
const indexes = indexesResult.rows;
|
|
1417
|
+
const columnsByTable = /* @__PURE__ */ new Map();
|
|
1418
|
+
for (const col of allColumns) {
|
|
1419
|
+
const key = `${col.schema}.${col.table_name}`;
|
|
1420
|
+
if (!columnsByTable.has(key)) columnsByTable.set(key, []);
|
|
1421
|
+
columnsByTable.get(key).push(col);
|
|
1422
|
+
}
|
|
1423
|
+
const pkByTable = /* @__PURE__ */ new Map();
|
|
1424
|
+
for (const pk of primaryKeys) {
|
|
1425
|
+
const key = `${pk.schema}.${pk.table_name}`;
|
|
1426
|
+
if (!pkByTable.has(key)) pkByTable.set(key, []);
|
|
1427
|
+
pkByTable.get(key).push(pk.column_name);
|
|
1428
|
+
}
|
|
1429
|
+
const fkByTable = /* @__PURE__ */ new Map();
|
|
1430
|
+
for (const fk of foreignKeys) {
|
|
1431
|
+
const key = `${fk.schema}.${fk.table_name}`;
|
|
1432
|
+
if (!fkByTable.has(key)) fkByTable.set(key, []);
|
|
1433
|
+
const fks = fkByTable.get(key);
|
|
1434
|
+
if (!fks.some((existing) => existing.constraint_name === fk.constraint_name)) {
|
|
1435
|
+
fks.push(fk);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
const indexesByTable = /* @__PURE__ */ new Map();
|
|
1439
|
+
for (const idx of indexes) {
|
|
1440
|
+
const key = `${idx.schema}.${idx.table_name}`;
|
|
1441
|
+
if (!indexesByTable.has(key)) indexesByTable.set(key, []);
|
|
1442
|
+
indexesByTable.get(key).push(idx);
|
|
1443
|
+
}
|
|
1444
|
+
const tableSummaries = tables.map((table) => {
|
|
1445
|
+
const key = `${table.schema}.${table.table_name}`;
|
|
1446
|
+
const columns = columnsByTable.get(key) || [];
|
|
1447
|
+
const pks = pkByTable.get(key) || [];
|
|
1448
|
+
const fks = fkByTable.get(key) || [];
|
|
1449
|
+
const tableIndexes = indexesByTable.get(key) || [];
|
|
1450
|
+
return {
|
|
1451
|
+
schema: table.schema,
|
|
1452
|
+
name: table.table_name,
|
|
1453
|
+
description: table.description,
|
|
1454
|
+
rowCount: table.row_count || 0,
|
|
1455
|
+
totalSize: table.total_size,
|
|
1456
|
+
tableSizeBytes: parseInt(table.table_size_bytes) || 0,
|
|
1457
|
+
indexSizeBytes: parseInt(table.index_size_bytes) || 0,
|
|
1458
|
+
deadTuples: table.dead_tuples || 0,
|
|
1459
|
+
businessIntent: inferBusinessIntent(
|
|
1460
|
+
table.table_name,
|
|
1461
|
+
columns.map((c) => c.column_name)
|
|
1462
|
+
),
|
|
1463
|
+
columns: columns.map((col) => ({
|
|
1464
|
+
name: col.column_name,
|
|
1465
|
+
type: col.data_type,
|
|
1466
|
+
nullable: col.is_nullable,
|
|
1467
|
+
defaultValue: col.default_value,
|
|
1468
|
+
description: col.description,
|
|
1469
|
+
isPrimaryKey: pks.includes(col.column_name),
|
|
1470
|
+
isForeignKey: fks.some((fk) => fk.column_name === col.column_name),
|
|
1471
|
+
referencedTable: fks.find((fk) => fk.column_name === col.column_name)?.referenced_table,
|
|
1472
|
+
referencedColumn: fks.find((fk) => fk.column_name === col.column_name)?.referenced_column
|
|
1473
|
+
})),
|
|
1474
|
+
primaryKeys: pks,
|
|
1475
|
+
foreignKeys: fks.map((fk) => ({
|
|
1476
|
+
column: fk.column_name,
|
|
1477
|
+
references: `${fk.referenced_schema}.${fk.referenced_table}.${fk.referenced_column}`
|
|
1478
|
+
})),
|
|
1479
|
+
indexes: tableIndexes.map((idx) => ({
|
|
1480
|
+
name: idx.index_name,
|
|
1481
|
+
type: idx.index_type,
|
|
1482
|
+
definition: idx.definition,
|
|
1483
|
+
isUnique: idx.is_unique,
|
|
1484
|
+
isPrimary: idx.is_primary,
|
|
1485
|
+
sizeBytes: parseInt(idx.size_bytes) || 0
|
|
1486
|
+
}))
|
|
1487
|
+
};
|
|
1488
|
+
});
|
|
1489
|
+
const indexSummary = tables.map((table) => {
|
|
1490
|
+
const key = `${table.schema}.${table.table_name}`;
|
|
1491
|
+
const tableIndexes = indexesByTable.get(key) || [];
|
|
1492
|
+
return {
|
|
1493
|
+
table: `${table.schema}.${table.table_name}`,
|
|
1494
|
+
hasIndexes: tableIndexes.length > 0,
|
|
1495
|
+
indexCount: tableIndexes.length,
|
|
1496
|
+
indexTypes: [...new Set(tableIndexes.map((i) => i.index_type))],
|
|
1497
|
+
primaryIndex: tableIndexes.some((i) => i.is_primary),
|
|
1498
|
+
uniqueIndexes: tableIndexes.filter((i) => i.is_unique).length
|
|
1499
|
+
};
|
|
1500
|
+
});
|
|
1501
|
+
return {
|
|
1502
|
+
database: {
|
|
1503
|
+
schema: tables[0]?.schema || "public",
|
|
1504
|
+
tableCount: tables.length,
|
|
1505
|
+
totalSize: tables.reduce((sum, t) => sum + (parseInt(t.total_size_bytes) || 0), 0),
|
|
1506
|
+
totalRows: tables.reduce((sum, t) => sum + (t.row_count || 0), 0)
|
|
1507
|
+
},
|
|
1508
|
+
tables: tableSummaries,
|
|
1509
|
+
indexSummary
|
|
1510
|
+
};
|
|
1511
|
+
} finally {
|
|
1512
|
+
client.release();
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
var init_db_context = __esm({
|
|
1516
|
+
"src/server/queries/db-context.ts"() {
|
|
1517
|
+
"use strict";
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
// src/server/llm.ts
|
|
1522
|
+
var llm_exports = {};
|
|
1523
|
+
__export(llm_exports, {
|
|
1524
|
+
executeNaturalQuery: () => executeNaturalQuery,
|
|
1525
|
+
explainSchemaDiff: () => explainSchemaDiff,
|
|
1526
|
+
generateAISuggestions: () => generateAISuggestions,
|
|
1527
|
+
getLLMConfig: () => getLLMConfig,
|
|
1528
|
+
validateSQL: () => validateSQL
|
|
1529
|
+
});
|
|
1530
|
+
function getLLMConfig() {
|
|
1531
|
+
const provider = process.env.PG_DASH_LLM_PROVIDER || "openai";
|
|
1532
|
+
return {
|
|
1533
|
+
provider,
|
|
1534
|
+
apiKey: process.env.PG_DASH_LLM_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.GOOGLE_API_KEY,
|
|
1535
|
+
baseUrl: process.env.PG_DASH_LLM_BASE_URL,
|
|
1536
|
+
model: process.env.PG_DASH_LLM_MODEL
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
async function buildDatabaseContext(pool) {
|
|
1540
|
+
const dbContext = await getDbContext(pool);
|
|
1541
|
+
const tableInfos = dbContext.tables.slice(0, 30).map((table) => {
|
|
1542
|
+
const columns = table.columns.map(
|
|
1543
|
+
(col) => ` - ${col.name}: ${col.type}${col.isPrimaryKey ? " (PK)" : ""}${col.isForeignKey ? ` (FK -> ${col.references?.table}.${col.references?.column})` : ""}`
|
|
1544
|
+
).join("\n");
|
|
1545
|
+
return `### ${table.schema}.${table.name} (${table.rowCount || "?"} rows, ${table.totalSize || "?"})
|
|
1546
|
+
${columns}`;
|
|
1547
|
+
}).join("\n\n");
|
|
1548
|
+
return `Database Schema (top tables by size):
|
|
1549
|
+
${tableInfos}
|
|
1550
|
+
|
|
1551
|
+
Generate a PostgreSQL SELECT query to answer the user's question.
|
|
1552
|
+
Rules:
|
|
1553
|
+
1. Only generate SELECT queries - no INSERT, UPDATE, DELETE, or DDL
|
|
1554
|
+
2. Use proper JOINs if needed
|
|
1555
|
+
3. Use LIMIT to cap results at 100 rows unless user specifies otherwise
|
|
1556
|
+
4. Use table aliases for clarity
|
|
1557
|
+
5. For time-based queries, use NOW() - INTERVAL syntax
|
|
1558
|
+
6. Use pg_ prefix system tables only if necessary
|
|
1559
|
+
|
|
1560
|
+
Return ONLY the SQL query, no explanations.`;
|
|
1561
|
+
}
|
|
1562
|
+
async function callLLM(config, systemPrompt, userPrompt) {
|
|
1563
|
+
const { provider, apiKey, baseUrl, model } = config;
|
|
1564
|
+
if (!apiKey) {
|
|
1565
|
+
throw new Error(`API key not configured. Set PG_DASH_LLM_API_KEY (or OPENAI_API_KEY/ANTHROPIC_API_KEY/GOOGLE_API_KEY)`);
|
|
1566
|
+
}
|
|
1567
|
+
const headers = {
|
|
1568
|
+
"Content-Type": "application/json"
|
|
1569
|
+
};
|
|
1570
|
+
let url;
|
|
1571
|
+
let body;
|
|
1572
|
+
switch (provider) {
|
|
1573
|
+
case "openai":
|
|
1574
|
+
url = (baseUrl || "https://api.openai.com/v1") + "/chat/completions";
|
|
1575
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
1576
|
+
body = {
|
|
1577
|
+
model: model || "gpt-4o-mini",
|
|
1578
|
+
messages: [
|
|
1579
|
+
{ role: "system", content: systemPrompt },
|
|
1580
|
+
{ role: "user", content: userPrompt }
|
|
1581
|
+
],
|
|
1582
|
+
temperature: 0
|
|
1583
|
+
};
|
|
1584
|
+
break;
|
|
1585
|
+
case "anthropic":
|
|
1586
|
+
url = (baseUrl || "https://api.anthropic.com/v1") + "/messages";
|
|
1587
|
+
headers["x-api-key"] = apiKey;
|
|
1588
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
1589
|
+
body = {
|
|
1590
|
+
model: model || "claude-3-haiku-20240307",
|
|
1591
|
+
system: systemPrompt,
|
|
1592
|
+
messages: [{ role: "user", content: userPrompt }],
|
|
1593
|
+
max_tokens: 1024
|
|
1594
|
+
};
|
|
1595
|
+
break;
|
|
1596
|
+
case "google":
|
|
1597
|
+
url = (baseUrl || "https://generativelanguage.googleapis.com/v1beta") + `/models/${model || "gemini-2.0-flash-exp"}:generateContent?key=${apiKey}`;
|
|
1598
|
+
body = {
|
|
1599
|
+
contents: [{ parts: [{ text: `System: ${systemPrompt}
|
|
1600
|
+
|
|
1601
|
+
User: ${userPrompt}` }] }],
|
|
1602
|
+
generationConfig: { temperature: 0 }
|
|
1603
|
+
};
|
|
1604
|
+
break;
|
|
1605
|
+
case "ollama":
|
|
1606
|
+
url = (baseUrl || "http://localhost:11434") + "/api/generate";
|
|
1607
|
+
body = {
|
|
1608
|
+
model: model || "llama3.2",
|
|
1609
|
+
prompt: `System: ${systemPrompt}
|
|
1610
|
+
|
|
1611
|
+
User: ${userPrompt}`,
|
|
1612
|
+
stream: false
|
|
1613
|
+
};
|
|
1614
|
+
break;
|
|
1615
|
+
default:
|
|
1616
|
+
throw new Error(`Unknown LLM provider: ${provider}`);
|
|
1617
|
+
}
|
|
1618
|
+
const response = await fetch(url, {
|
|
1619
|
+
method: "POST",
|
|
1620
|
+
headers,
|
|
1621
|
+
body: JSON.stringify(body)
|
|
1622
|
+
});
|
|
1623
|
+
if (!response.ok) {
|
|
1624
|
+
const errorText = await response.text();
|
|
1625
|
+
throw new Error(`LLM API error (${response.status}): ${errorText}`);
|
|
1626
|
+
}
|
|
1627
|
+
const data = await response.json();
|
|
1628
|
+
switch (provider) {
|
|
1629
|
+
case "openai":
|
|
1630
|
+
return data.choices?.[0]?.message?.content?.trim() || "";
|
|
1631
|
+
case "anthropic":
|
|
1632
|
+
return data.content?.[0]?.text?.trim() || "";
|
|
1633
|
+
case "google":
|
|
1634
|
+
return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || "";
|
|
1635
|
+
case "ollama":
|
|
1636
|
+
return data.response?.trim() || "";
|
|
1637
|
+
default:
|
|
1638
|
+
return "";
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
function validateSQL(sql) {
|
|
1642
|
+
const trimmed = sql.trim();
|
|
1643
|
+
if (!/^\s*SELECT\b/i.test(trimmed)) {
|
|
1644
|
+
return { valid: false, error: "Only SELECT queries are allowed" };
|
|
1645
|
+
}
|
|
1646
|
+
const dangerous = [
|
|
1647
|
+
/;\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE)/i,
|
|
1648
|
+
/\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE)\b/i,
|
|
1649
|
+
/pg_terminate_backend/i,
|
|
1650
|
+
/pg_cancel_backend/i,
|
|
1651
|
+
/\bCOPY\b/i,
|
|
1652
|
+
/\bEXPLAIN\b.*\b(SELECT|INSERT|UPDATE|DELETE)\b/i
|
|
1653
|
+
// Allow EXPLAIN but wrap it
|
|
1654
|
+
];
|
|
1655
|
+
for (const pattern of dangerous) {
|
|
1656
|
+
if (pattern.test(trimmed)) {
|
|
1657
|
+
return { valid: false, error: `Disallowed pattern in query: ${pattern.source}` };
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
let finalSql = trimmed;
|
|
1661
|
+
if (!/\bLIMIT\b/i.test(trimmed)) {
|
|
1662
|
+
finalSql = `${trimmed} LIMIT 100`;
|
|
1663
|
+
}
|
|
1664
|
+
return { valid: true, sql: finalSql };
|
|
1665
|
+
}
|
|
1666
|
+
async function executeNaturalQuery(pool, naturalQuery, config) {
|
|
1667
|
+
const llmConfig = config || getLLMConfig();
|
|
1668
|
+
const contextPrompt = await buildDatabaseContext(pool);
|
|
1669
|
+
const fullPrompt = `${contextPrompt}
|
|
1670
|
+
|
|
1671
|
+
User's question: ${naturalQuery}
|
|
1672
|
+
|
|
1673
|
+
Generate the SQL query now:`;
|
|
1674
|
+
let sql;
|
|
1675
|
+
try {
|
|
1676
|
+
sql = await callLLM(
|
|
1677
|
+
llmConfig,
|
|
1678
|
+
"You are a PostgreSQL expert. Generate only SELECT queries based on the schema provided.",
|
|
1679
|
+
fullPrompt
|
|
1680
|
+
);
|
|
1681
|
+
} catch (err) {
|
|
1682
|
+
return {
|
|
1683
|
+
answer: "",
|
|
1684
|
+
sql: "",
|
|
1685
|
+
error: `LLM call failed: ${err.message}`
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
const sqlMatch = sql.match(/```sql\n?([\s\S]*?)```/) || sql.match(/```\n?([\s\S]*?)```/) || [null, sql];
|
|
1689
|
+
let extractedSql = sqlMatch[1]?.trim() || sql.trim();
|
|
1690
|
+
const validation = validateSQL(extractedSql);
|
|
1691
|
+
if (!validation.valid) {
|
|
1692
|
+
return {
|
|
1693
|
+
answer: "",
|
|
1694
|
+
sql: extractedSql,
|
|
1695
|
+
error: `SQL validation failed: ${validation.error}`
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
extractedSql = validation.sql;
|
|
1699
|
+
let result;
|
|
1700
|
+
const client = await pool.connect();
|
|
1701
|
+
try {
|
|
1702
|
+
const queryResult = await client.query(extractedSql);
|
|
1703
|
+
result = {
|
|
1704
|
+
rows: queryResult.rows,
|
|
1705
|
+
rowCount: queryResult.rowCount || 0,
|
|
1706
|
+
columns: queryResult.fields?.map((f) => f.name) || []
|
|
1707
|
+
};
|
|
1708
|
+
} catch (err) {
|
|
1709
|
+
return {
|
|
1710
|
+
answer: "",
|
|
1711
|
+
sql: extractedSql,
|
|
1712
|
+
error: `SQL execution failed: ${err.message}`
|
|
1713
|
+
};
|
|
1714
|
+
} finally {
|
|
1715
|
+
client.release();
|
|
1716
|
+
}
|
|
1717
|
+
let answer = "";
|
|
1718
|
+
if (result.rows.length === 0) {
|
|
1719
|
+
answer = "No results found for your query.";
|
|
1720
|
+
} else if (result.rows.length === 1) {
|
|
1721
|
+
answer = `Found 1 result: ${JSON.stringify(result.rows[0])}`;
|
|
1722
|
+
} else {
|
|
1723
|
+
answer = `Found ${result.rowCount} results. Showing first ${Math.min(result.rows.length, 10)}:`;
|
|
1724
|
+
answer += "\n\n" + JSON.stringify(result.rows.slice(0, 10), null, 2);
|
|
1725
|
+
if (result.rows.length > 10) {
|
|
1726
|
+
answer += `
|
|
1727
|
+
|
|
1728
|
+
... and ${result.rows.length - 10} more rows (limited to 100)`;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
return {
|
|
1732
|
+
answer,
|
|
1733
|
+
sql: extractedSql,
|
|
1734
|
+
result
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
async function generateAISuggestions(report, config) {
|
|
1738
|
+
const llmConfig = config || getLLMConfig();
|
|
1739
|
+
if (!llmConfig.apiKey) {
|
|
1740
|
+
return {
|
|
1741
|
+
summary: `Health Score: ${report.score}/100 (${report.grade}). ${report.issues.length} issues found.`,
|
|
1742
|
+
suggestions: report.issues.map((issue) => ({
|
|
1743
|
+
issue: issue.title,
|
|
1744
|
+
suggestion: issue.description,
|
|
1745
|
+
priority: issue.severity
|
|
1746
|
+
}))
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
const issuesText = report.issues.map(
|
|
1750
|
+
(i) => `- [${i.severity}] ${i.title}: ${i.description}`
|
|
1751
|
+
).join("\n");
|
|
1752
|
+
const prompt = `You are a PostgreSQL database expert. Analyze this health check report and provide:
|
|
1753
|
+
1. A one-sentence summary of the overall database health status
|
|
1754
|
+
2. Prioritized fix suggestions for each issue (most critical first)
|
|
1755
|
+
|
|
1756
|
+
Health Report:
|
|
1757
|
+
- Score: ${report.score}/100 (Grade: ${report.grade})
|
|
1758
|
+
- Issues: ${report.issues.length}
|
|
1759
|
+
|
|
1760
|
+
Issues:
|
|
1761
|
+
${issuesText}
|
|
1762
|
+
|
|
1763
|
+
Return a JSON object with this exact structure:
|
|
1764
|
+
{
|
|
1765
|
+
"summary": "one sentence summary",
|
|
1766
|
+
"suggestions": [
|
|
1767
|
+
{ "issue": "issue title", "suggestion": "what to do", "priority": "critical|warning|info" }
|
|
1768
|
+
]
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
Only include issues that have actionable suggestions. Prioritize by severity (critical > warning > info).`;
|
|
1772
|
+
try {
|
|
1773
|
+
const response = await callLLM(
|
|
1774
|
+
llmConfig,
|
|
1775
|
+
"You are a PostgreSQL expert. Return only valid JSON.",
|
|
1776
|
+
prompt
|
|
1777
|
+
);
|
|
1778
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
1779
|
+
if (jsonMatch) {
|
|
1780
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
1781
|
+
return {
|
|
1782
|
+
summary: parsed.summary || `Health Score: ${report.score}/100 (${report.grade})`,
|
|
1783
|
+
suggestions: parsed.suggestions || []
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
} catch (err) {
|
|
1787
|
+
console.error("[llm] AI suggestions error:", err);
|
|
1788
|
+
}
|
|
1789
|
+
return {
|
|
1790
|
+
summary: `Health Score: ${report.score}/100 (${report.grade}). ${report.issues.length} issues found.`,
|
|
1791
|
+
suggestions: report.issues.map((issue) => ({
|
|
1792
|
+
issue: issue.title,
|
|
1793
|
+
suggestion: issue.description,
|
|
1794
|
+
priority: issue.severity
|
|
1795
|
+
}))
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
async function explainSchemaDiff(diff, config) {
|
|
1799
|
+
const llmConfig = config || getLLMConfig();
|
|
1800
|
+
if (!llmConfig.apiKey) {
|
|
1801
|
+
const parts = [];
|
|
1802
|
+
if (diff.schema.missingTables.length > 0) {
|
|
1803
|
+
parts.push(`Missing tables: ${diff.schema.missingTables.join(", ")}`);
|
|
1804
|
+
}
|
|
1805
|
+
if (diff.schema.extraTables.length > 0) {
|
|
1806
|
+
parts.push(`Extra tables: ${diff.schema.extraTables.join(", ")}`);
|
|
1807
|
+
}
|
|
1808
|
+
if (diff.schema.columnDiffs.length > 0) {
|
|
1809
|
+
parts.push(`Column changes in ${diff.schema.columnDiffs.length} tables`);
|
|
1810
|
+
}
|
|
1811
|
+
return parts.length > 0 ? parts.join("; ") : "No schema differences found.";
|
|
1812
|
+
}
|
|
1813
|
+
const changes = [];
|
|
1814
|
+
for (const t of diff.schema.missingTables) {
|
|
1815
|
+
changes.push(`- Table '${t}' exists in source but not in target`);
|
|
1816
|
+
}
|
|
1817
|
+
for (const t of diff.schema.extraTables) {
|
|
1818
|
+
changes.push(`- Table '${t}' exists in target but not in source`);
|
|
1819
|
+
}
|
|
1820
|
+
for (const cd of diff.schema.columnDiffs) {
|
|
1821
|
+
for (const col of cd.missingColumns) {
|
|
1822
|
+
changes.push(`- Table '${cd.table}' missing column '${col.name}' (${col.type})`);
|
|
1823
|
+
}
|
|
1824
|
+
for (const col of cd.extraColumns) {
|
|
1825
|
+
changes.push(`- Table '${cd.table}' has extra column '${col.name}' (${col.type})`);
|
|
1826
|
+
}
|
|
1827
|
+
for (const td of cd.typeDiffs) {
|
|
1828
|
+
changes.push(`- Table '${cd.table}' column '${td.column}' type changed: ${td.sourceType} \u2192 ${td.targetType}`);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
const prompt = `You are a PostgreSQL database expert. Explain the business impact of these schema differences in plain English.
|
|
1832
|
+
Focus on what these changes likely mean for the application (e.g., "Table 'orders' added column 'status' \u2014 likely for order state tracking").
|
|
1833
|
+
|
|
1834
|
+
Schema Differences:
|
|
1835
|
+
${changes.join("\n") || "No differences"}
|
|
1836
|
+
|
|
1837
|
+
Return a natural language explanation that is:
|
|
1838
|
+
1. Concise but informative
|
|
1839
|
+
2. Focused on business impact
|
|
1840
|
+
3. Developer-friendly
|
|
1841
|
+
|
|
1842
|
+
Example format:
|
|
1843
|
+
"Table 'orders' added column 'status' \u2014 likely for order state tracking"
|
|
1844
|
+
"Table 'users' missing column 'email' \u2014 may break password reset functionality"
|
|
1845
|
+
|
|
1846
|
+
Return only the explanation, no JSON:`;
|
|
1847
|
+
try {
|
|
1848
|
+
const response = await callLLM(
|
|
1849
|
+
llmConfig,
|
|
1850
|
+
"You are a PostgreSQL expert. Provide clear, actionable explanations.",
|
|
1851
|
+
prompt
|
|
1852
|
+
);
|
|
1853
|
+
return response.trim();
|
|
1854
|
+
} catch (err) {
|
|
1855
|
+
console.error("[llm] Schema diff explanation error:", err);
|
|
1856
|
+
return "Unable to generate AI explanation. Review the diff manually.";
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
var init_llm = __esm({
|
|
1860
|
+
"src/server/llm.ts"() {
|
|
1861
|
+
"use strict";
|
|
1862
|
+
init_db_context();
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1278
1866
|
// src/server/migration-checker.ts
|
|
1279
1867
|
var migration_checker_exports = {};
|
|
1280
1868
|
__export(migration_checker_exports, {
|
|
@@ -4166,6 +4754,8 @@ var { values, positionals } = parseArgs({
|
|
|
4166
4754
|
format: { type: "string", short: "f" },
|
|
4167
4755
|
ci: { type: "boolean", default: false },
|
|
4168
4756
|
diff: { type: "boolean", default: false },
|
|
4757
|
+
"ai-suggest": { type: "boolean", default: false },
|
|
4758
|
+
"ai-explain": { type: "boolean", default: false },
|
|
4169
4759
|
"snapshot-path": { type: "string" },
|
|
4170
4760
|
source: { type: "string" },
|
|
4171
4761
|
target: { type: "string" },
|
|
@@ -4195,6 +4785,8 @@ Usage:
|
|
|
4195
4785
|
pg-dash watch-locks <connection> Real-time lock + long-query monitor
|
|
4196
4786
|
pg-dash diff-env --source <url> --target <url> Compare two environments
|
|
4197
4787
|
pg-dash schema-diff <connection-string> Show latest schema changes
|
|
4788
|
+
pg-dash query-stats export <connection> Export query statistics (PG 18+)
|
|
4789
|
+
pg-dash query-stats import <file> <connection> Import query statistics (PG 18+)
|
|
4198
4790
|
pg-dash --host localhost --user postgres --db mydb
|
|
4199
4791
|
|
|
4200
4792
|
Options:
|
|
@@ -4222,6 +4814,8 @@ Options:
|
|
|
4222
4814
|
-f, --format <fmt> Output format: text|json|md (default: text)
|
|
4223
4815
|
--ci Output GitHub Actions compatible annotations
|
|
4224
4816
|
--diff Compare with previous run (saves snapshot for next run)
|
|
4817
|
+
--ai-suggest Use AI to generate fix suggestions (requires LLM config)
|
|
4818
|
+
--ai-explain Use AI to explain schema diff business impact (requires LLM config)
|
|
4225
4819
|
--snapshot-path <path> Path to snapshot file for --diff (default: ~/.pg-dash/last-check.json)
|
|
4226
4820
|
--source <url> Source database connection string (diff-env)
|
|
4227
4821
|
--target <url> Target database connection string (diff-env)
|
|
@@ -4234,7 +4828,7 @@ Environment variables:
|
|
|
4234
4828
|
`);
|
|
4235
4829
|
process.exit(0);
|
|
4236
4830
|
}
|
|
4237
|
-
var KNOWN_SUBCOMMANDS = ["check", "health", "check-migration", "schema-diff", "diff-env", "explain", "watch-locks"];
|
|
4831
|
+
var KNOWN_SUBCOMMANDS = ["check", "health", "check-migration", "schema-diff", "diff-env", "explain", "watch-locks", "query-stats"];
|
|
4238
4832
|
var subcommand = positionals[0];
|
|
4239
4833
|
function isValidConnectionString(s) {
|
|
4240
4834
|
return s.startsWith("postgresql://") || s.startsWith("postgres://") || s.includes("@") || // user@host shorthand
|
|
@@ -4273,9 +4867,11 @@ if (subcommand === "check" || subcommand === "health") {
|
|
|
4273
4867
|
const format = values.format || "text";
|
|
4274
4868
|
const ci = values.ci || false;
|
|
4275
4869
|
const useDiff = values.diff || false;
|
|
4870
|
+
const aiSuggest = values["ai-suggest"] || false;
|
|
4276
4871
|
const { Pool: Pool3 } = await import("pg");
|
|
4277
4872
|
const { getAdvisorReport: getAdvisorReport2 } = await Promise.resolve().then(() => (init_advisor(), advisor_exports));
|
|
4278
4873
|
const { saveSnapshot: saveSnapshot2, loadSnapshot: loadSnapshot2, diffSnapshots: diffSnapshots2 } = await Promise.resolve().then(() => (init_snapshot(), snapshot_exports));
|
|
4874
|
+
const { generateAISuggestions: generateAISuggestions2, getLLMConfig: getLLMConfig2 } = await Promise.resolve().then(() => (init_llm(), llm_exports));
|
|
4279
4875
|
const os4 = await import("os");
|
|
4280
4876
|
const pool = new Pool3({ connectionString, connectionTimeoutMillis: 1e4 });
|
|
4281
4877
|
const checkDataDir = values["data-dir"] || path5.join(os4.homedir(), ".pg-dash");
|
|
@@ -4349,6 +4945,26 @@ if (subcommand === "check" || subcommand === "health") {
|
|
|
4349
4945
|
}
|
|
4350
4946
|
console.log("```");
|
|
4351
4947
|
}
|
|
4948
|
+
if (aiSuggest && report.issues.length > 0) {
|
|
4949
|
+
console.log(`
|
|
4950
|
+
### \u{1F916} AI Suggestions
|
|
4951
|
+
`);
|
|
4952
|
+
try {
|
|
4953
|
+
const aiResult = await generateAISuggestions2(report, getLLMConfig2());
|
|
4954
|
+
console.log(`**Summary:** ${aiResult.summary}
|
|
4955
|
+
`);
|
|
4956
|
+
if (aiResult.suggestions.length > 0) {
|
|
4957
|
+
console.log(`| Priority | Issue | Suggestion |`);
|
|
4958
|
+
console.log(`|----------|-------|------------|`);
|
|
4959
|
+
for (const s of aiResult.suggestions) {
|
|
4960
|
+
const icon = s.priority === "critical" ? "\u{1F534}" : s.priority === "warning" ? "\u{1F7E1}" : "\u{1F535}";
|
|
4961
|
+
console.log(`| ${icon} ${s.priority} | ${s.issue} | ${s.suggestion} |`);
|
|
4962
|
+
}
|
|
4963
|
+
}
|
|
4964
|
+
} catch (err) {
|
|
4965
|
+
console.log(`*AI suggestions unavailable: ${err.message}*`);
|
|
4966
|
+
}
|
|
4967
|
+
}
|
|
4352
4968
|
} else if (ci) {
|
|
4353
4969
|
for (const issue of report.issues) {
|
|
4354
4970
|
const level = issue.severity === "critical" ? "error" : issue.severity === "warning" ? "warning" : "notice";
|
|
@@ -4366,6 +4982,23 @@ Score: ${diff.previousScore} \u2192 ${report.score} (${sign}${diff.scoreDelta})`
|
|
|
4366
4982
|
console.log(`Resolved: ${diff.resolvedIssues.length} issues`);
|
|
4367
4983
|
console.log(`New: ${diff.newIssues.length} issues`);
|
|
4368
4984
|
}
|
|
4985
|
+
if (aiSuggest && report.issues.length > 0) {
|
|
4986
|
+
console.log(`
|
|
4987
|
+
## \u{1F916} AI Fix Suggestions
|
|
4988
|
+
`);
|
|
4989
|
+
try {
|
|
4990
|
+
const aiResult = await generateAISuggestions2(report, getLLMConfig2());
|
|
4991
|
+
console.log(aiResult.summary);
|
|
4992
|
+
console.log();
|
|
4993
|
+
for (const s of aiResult.suggestions) {
|
|
4994
|
+
const sev = s.priority === "critical" ? "error" : s.priority === "warning" ? "warning" : "notice";
|
|
4995
|
+
console.log(`::${sev}::${s.issue}`);
|
|
4996
|
+
console.log(` Suggestion: ${s.suggestion}`);
|
|
4997
|
+
}
|
|
4998
|
+
} catch (err) {
|
|
4999
|
+
console.log(`*AI suggestions unavailable*`);
|
|
5000
|
+
}
|
|
5001
|
+
}
|
|
4369
5002
|
} else {
|
|
4370
5003
|
if (diff) {
|
|
4371
5004
|
const sign = diff.scoreDelta >= 0 ? "+" : "";
|
|
@@ -4398,6 +5031,23 @@ Score: ${diff.previousScore} \u2192 ${report.score} (${sign}${diff.scoreDelta})`
|
|
|
4398
5031
|
console.log(` ${icon} [${issue.severity}] ${issue.title}`);
|
|
4399
5032
|
}
|
|
4400
5033
|
}
|
|
5034
|
+
if (aiSuggest && report.issues.length > 0) {
|
|
5035
|
+
console.log(`
|
|
5036
|
+
\u{1F916} AI Suggestions:
|
|
5037
|
+
`);
|
|
5038
|
+
try {
|
|
5039
|
+
const aiResult = await generateAISuggestions2(report, getLLMConfig2());
|
|
5040
|
+
console.log(` ${aiResult.summary}
|
|
5041
|
+
`);
|
|
5042
|
+
for (const s of aiResult.suggestions.slice(0, 5)) {
|
|
5043
|
+
const icon = s.priority === "critical" ? "\u{1F534}" : s.priority === "warning" ? "\u{1F7E1}" : "\u{1F535}";
|
|
5044
|
+
console.log(` ${icon} [${s.priority}] ${s.issue}`);
|
|
5045
|
+
console.log(` \u2192 ${s.suggestion}`);
|
|
5046
|
+
}
|
|
5047
|
+
} catch (err) {
|
|
5048
|
+
console.log(` *AI suggestions unavailable*`);
|
|
5049
|
+
}
|
|
5050
|
+
}
|
|
4401
5051
|
console.log();
|
|
4402
5052
|
}
|
|
4403
5053
|
await pool.end();
|
|
@@ -4540,16 +5190,40 @@ Migration check: ${filePath}`);
|
|
|
4540
5190
|
const format = values.format || "text";
|
|
4541
5191
|
const includeHealth = values.health || false;
|
|
4542
5192
|
const ci = values.ci || false;
|
|
5193
|
+
const aiExplain = values["ai-explain"] || false;
|
|
4543
5194
|
const { diffEnvironments: diffEnvironments2, formatTextDiff: formatTextDiff2, formatMdDiff: formatMdDiff2 } = await Promise.resolve().then(() => (init_env_differ(), env_differ_exports));
|
|
5195
|
+
const { explainSchemaDiff: explainSchemaDiff2, getLLMConfig: getLLMConfig2 } = await Promise.resolve().then(() => (init_llm(), llm_exports));
|
|
4544
5196
|
try {
|
|
4545
5197
|
const result = await diffEnvironments2(sourceUrl, targetUrl, { includeHealth });
|
|
4546
5198
|
if (format === "json") {
|
|
4547
5199
|
console.log(JSON.stringify(result, null, 2));
|
|
4548
5200
|
} else if (format === "md") {
|
|
4549
5201
|
console.log(formatMdDiff2(result));
|
|
5202
|
+
if (aiExplain) {
|
|
5203
|
+
console.log(`
|
|
5204
|
+
## \u{1F916} AI Business Impact Analysis
|
|
5205
|
+
`);
|
|
5206
|
+
try {
|
|
5207
|
+
const explanation = await explainSchemaDiff2(result, getLLMConfig2());
|
|
5208
|
+
console.log(explanation);
|
|
5209
|
+
} catch (err) {
|
|
5210
|
+
console.log(`*AI explanation unavailable: ${err.message}*`);
|
|
5211
|
+
}
|
|
5212
|
+
}
|
|
4550
5213
|
} else {
|
|
4551
5214
|
const text = formatTextDiff2(result);
|
|
4552
5215
|
console.log(text);
|
|
5216
|
+
if (aiExplain) {
|
|
5217
|
+
console.log(`
|
|
5218
|
+
\u{1F916} Business Impact:
|
|
5219
|
+
`);
|
|
5220
|
+
try {
|
|
5221
|
+
const explanation = await explainSchemaDiff2(result, getLLMConfig2());
|
|
5222
|
+
console.log(` ${explanation}`);
|
|
5223
|
+
} catch (err) {
|
|
5224
|
+
console.log(` *AI explanation unavailable*`);
|
|
5225
|
+
}
|
|
5226
|
+
}
|
|
4553
5227
|
if (ci) {
|
|
4554
5228
|
for (const t of result.schema.missingTables) {
|
|
4555
5229
|
console.log(`::error::diff-env: target missing table: ${t}`);
|
|
@@ -4751,6 +5425,118 @@ ${bold}${yellow} Long-running Queries (${report.longRunningQueries.length})${re
|
|
|
4751
5425
|
await tick();
|
|
4752
5426
|
const timer = setInterval(tick, intervalSec * 1e3);
|
|
4753
5427
|
void timer;
|
|
5428
|
+
} else if (subcommand === "query-stats") {
|
|
5429
|
+
const action = positionals[1];
|
|
5430
|
+
const { Pool: Pool3 } = await import("pg");
|
|
5431
|
+
const fs6 = await import("fs");
|
|
5432
|
+
if (action === "export") {
|
|
5433
|
+
const connStr = positionals[2] || resolveConnectionString(2);
|
|
5434
|
+
if (!connStr) {
|
|
5435
|
+
console.error("Error: provide a connection string.\n\nUsage: pg-dash query-stats export <connection> [--file output.json]");
|
|
5436
|
+
process.exit(1);
|
|
5437
|
+
}
|
|
5438
|
+
const outputFile = values["snapshot-path"] || "query-stats.json";
|
|
5439
|
+
const pool = new Pool3({ connectionString: connStr, max: 1, connectionTimeoutMillis: 1e4 });
|
|
5440
|
+
try {
|
|
5441
|
+
const versionRes = await pool.query("SHOW server_version_num");
|
|
5442
|
+
const versionNum = parseInt(versionRes.rows[0].server_version_num, 10);
|
|
5443
|
+
if (versionNum < 18e4) {
|
|
5444
|
+
console.error("Error: query-stats export requires PostgreSQL 18+. Current:", versionRes.rows[0].server_version_num);
|
|
5445
|
+
process.exit(1);
|
|
5446
|
+
}
|
|
5447
|
+
const stats = await pool.query("SELECT pg_stat_statements_reset()");
|
|
5448
|
+
const res = await pool.query(`
|
|
5449
|
+
SELECT
|
|
5450
|
+
s.datname,
|
|
5451
|
+
s.relkind,
|
|
5452
|
+
s.relname,
|
|
5453
|
+
s.seq_scan,
|
|
5454
|
+
s.seq_tup_read,
|
|
5455
|
+
s.idx_scan,
|
|
5456
|
+
s.idx_tup_fetch,
|
|
5457
|
+
s.n_tup_ins,
|
|
5458
|
+
s.n_tup_upd,
|
|
5459
|
+
s.n_tup_del,
|
|
5460
|
+
s.n_live_tup,
|
|
5461
|
+
s.n_dead_tup,
|
|
5462
|
+
s.vacuum_count,
|
|
5463
|
+
s.autovacuum_count,
|
|
5464
|
+
s.last_vacuum,
|
|
5465
|
+
s.last_autovacuum,
|
|
5466
|
+
s.last_autovacuum_age,
|
|
5467
|
+
s.last_data_change_age,
|
|
5468
|
+
s.changes_since_analyze,
|
|
5469
|
+
s.changes_since_autovacuum,
|
|
5470
|
+
s.tuple_count,
|
|
5471
|
+
s.tuple_per_read,
|
|
5472
|
+
s.tuple_written_per_sec
|
|
5473
|
+
FROM pg_stat_user_tables s
|
|
5474
|
+
ORDER BY seq_scan DESC
|
|
5475
|
+
LIMIT 100
|
|
5476
|
+
`);
|
|
5477
|
+
const exportData = {
|
|
5478
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5479
|
+
pgVersion: versionRes.rows[0].server_version_num,
|
|
5480
|
+
tables: res.rows
|
|
5481
|
+
};
|
|
5482
|
+
fs6.writeFileSync(outputFile, JSON.stringify(exportData, null, 2));
|
|
5483
|
+
console.log(`Exported query statistics to ${outputFile}`);
|
|
5484
|
+
console.log(`Tables: ${res.rows.length}, Size: ${JSON.stringify(exportData).length} bytes`);
|
|
5485
|
+
await pool.end();
|
|
5486
|
+
} catch (err) {
|
|
5487
|
+
console.error(`Error: ${err.message}`);
|
|
5488
|
+
await pool.end();
|
|
5489
|
+
process.exit(1);
|
|
5490
|
+
}
|
|
5491
|
+
} else if (action === "import") {
|
|
5492
|
+
const inputFile = positionals[2];
|
|
5493
|
+
const connStr = positionals[3] || resolveConnectionString(3);
|
|
5494
|
+
if (!inputFile) {
|
|
5495
|
+
console.error("Error: provide an input file.\n\nUsage: pg-dash query-stats import <file> <connection>");
|
|
5496
|
+
process.exit(1);
|
|
5497
|
+
}
|
|
5498
|
+
if (!connStr) {
|
|
5499
|
+
console.error("Error: provide a connection string.\n\nUsage: pg-dash query-stats import <file> <connection>");
|
|
5500
|
+
process.exit(1);
|
|
5501
|
+
}
|
|
5502
|
+
if (!fs6.existsSync(inputFile)) {
|
|
5503
|
+
console.error(`Error: file not found: ${inputFile}`);
|
|
5504
|
+
process.exit(1);
|
|
5505
|
+
}
|
|
5506
|
+
const pool = new Pool3({ connectionString: connStr, max: 1, connectionTimeoutMillis: 1e4 });
|
|
5507
|
+
try {
|
|
5508
|
+
const versionRes = await pool.query("SHOW server_version_num");
|
|
5509
|
+
const versionNum = parseInt(versionRes.rows[0].server_version_num, 10);
|
|
5510
|
+
if (versionNum < 18e4) {
|
|
5511
|
+
console.error("Error: query-stats import requires PostgreSQL 18+. Current:", versionRes.rows[0].server_version_num);
|
|
5512
|
+
process.exit(1);
|
|
5513
|
+
}
|
|
5514
|
+
const importData = JSON.parse(fs6.readFileSync(inputFile, "utf-8"));
|
|
5515
|
+
console.log(`Importing query statistics from ${inputFile}`);
|
|
5516
|
+
console.log(`PG version: ${importData.pgVersion} -> current: ${versionRes.rows[0].server_version_num}`);
|
|
5517
|
+
await pool.query(`
|
|
5518
|
+
CREATE TEMP TABLE _imported_stats (LIKE pg_stat_user_tables INCLUDING ALL)
|
|
5519
|
+
`);
|
|
5520
|
+
for (const row of importData.tables) {
|
|
5521
|
+
const cols = Object.keys(row).join(", ");
|
|
5522
|
+
const vals = Object.values(row).map((v) => v === null ? "NULL" : typeof v === "string" ? `'${v.replace(/'/g, "''")}'` : v).join(", ");
|
|
5523
|
+
try {
|
|
5524
|
+
await pool.query(`INSERT INTO _imported_stats (${cols}) VALUES (${vals})`);
|
|
5525
|
+
} catch (e) {
|
|
5526
|
+
}
|
|
5527
|
+
}
|
|
5528
|
+
await pool.query("SELECT pg_restore_relation_stats('_imported_stats'::regclass)");
|
|
5529
|
+
console.log(`Imported statistics for ${importData.tables.length} tables`);
|
|
5530
|
+
await pool.end();
|
|
5531
|
+
} catch (err) {
|
|
5532
|
+
console.error(`Error: ${err.message}`);
|
|
5533
|
+
await pool.end();
|
|
5534
|
+
process.exit(1);
|
|
5535
|
+
}
|
|
5536
|
+
} else {
|
|
5537
|
+
console.error("Error: specify 'export' or 'import'.\n\nUsage:\n pg-dash query-stats export <connection> [--file output.json]\n pg-dash query-stats import <file> <connection>");
|
|
5538
|
+
process.exit(1);
|
|
5539
|
+
}
|
|
4754
5540
|
} else {
|
|
4755
5541
|
if (subcommand && !isValidConnectionString(subcommand) && KNOWN_SUBCOMMANDS.indexOf(subcommand) === -1) {
|
|
4756
5542
|
console.error(
|