@indiekitai/pg-dash 0.6.0 → 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/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" },
@@ -4224,6 +4814,8 @@ Options:
4224
4814
  -f, --format <fmt> Output format: text|json|md (default: text)
4225
4815
  --ci Output GitHub Actions compatible annotations
4226
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)
4227
4819
  --snapshot-path <path> Path to snapshot file for --diff (default: ~/.pg-dash/last-check.json)
4228
4820
  --source <url> Source database connection string (diff-env)
4229
4821
  --target <url> Target database connection string (diff-env)
@@ -4275,9 +4867,11 @@ if (subcommand === "check" || subcommand === "health") {
4275
4867
  const format = values.format || "text";
4276
4868
  const ci = values.ci || false;
4277
4869
  const useDiff = values.diff || false;
4870
+ const aiSuggest = values["ai-suggest"] || false;
4278
4871
  const { Pool: Pool3 } = await import("pg");
4279
4872
  const { getAdvisorReport: getAdvisorReport2 } = await Promise.resolve().then(() => (init_advisor(), advisor_exports));
4280
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));
4281
4875
  const os4 = await import("os");
4282
4876
  const pool = new Pool3({ connectionString, connectionTimeoutMillis: 1e4 });
4283
4877
  const checkDataDir = values["data-dir"] || path5.join(os4.homedir(), ".pg-dash");
@@ -4351,6 +4945,26 @@ if (subcommand === "check" || subcommand === "health") {
4351
4945
  }
4352
4946
  console.log("```");
4353
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
+ }
4354
4968
  } else if (ci) {
4355
4969
  for (const issue of report.issues) {
4356
4970
  const level = issue.severity === "critical" ? "error" : issue.severity === "warning" ? "warning" : "notice";
@@ -4368,6 +4982,23 @@ Score: ${diff.previousScore} \u2192 ${report.score} (${sign}${diff.scoreDelta})`
4368
4982
  console.log(`Resolved: ${diff.resolvedIssues.length} issues`);
4369
4983
  console.log(`New: ${diff.newIssues.length} issues`);
4370
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
+ }
4371
5002
  } else {
4372
5003
  if (diff) {
4373
5004
  const sign = diff.scoreDelta >= 0 ? "+" : "";
@@ -4400,6 +5031,23 @@ Score: ${diff.previousScore} \u2192 ${report.score} (${sign}${diff.scoreDelta})`
4400
5031
  console.log(` ${icon} [${issue.severity}] ${issue.title}`);
4401
5032
  }
4402
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
+ }
4403
5051
  console.log();
4404
5052
  }
4405
5053
  await pool.end();
@@ -4542,16 +5190,40 @@ Migration check: ${filePath}`);
4542
5190
  const format = values.format || "text";
4543
5191
  const includeHealth = values.health || false;
4544
5192
  const ci = values.ci || false;
5193
+ const aiExplain = values["ai-explain"] || false;
4545
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));
4546
5196
  try {
4547
5197
  const result = await diffEnvironments2(sourceUrl, targetUrl, { includeHealth });
4548
5198
  if (format === "json") {
4549
5199
  console.log(JSON.stringify(result, null, 2));
4550
5200
  } else if (format === "md") {
4551
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
+ }
4552
5213
  } else {
4553
5214
  const text = formatTextDiff2(result);
4554
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
+ }
4555
5227
  if (ci) {
4556
5228
  for (const t of result.schema.missingTables) {
4557
5229
  console.log(`::error::diff-env: target missing table: ${t}`);