@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/README.md +8 -3
- package/README.zh-CN.md +8 -3
- package/dist/cli.js +672 -0
- 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" },
|
|
@@ -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}`);
|