@indiekitai/pg-dash 0.3.8 → 0.3.9

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 CHANGED
@@ -102,7 +102,7 @@ The Dashboard is there when you need it. But the real power is in the CLI, MCP,
102
102
 
103
103
  ### 🛡️ Migration Safety Check
104
104
  - Analyze a migration SQL file for risks before running it
105
- - Detects: `CREATE INDEX` without `CONCURRENTLY` (lock risk), `ADD COLUMN NOT NULL` without `DEFAULT`, `DROP TABLE`, `TRUNCATE`, `DELETE`/`UPDATE` without `WHERE`
105
+ - Detects: `CREATE INDEX` without `CONCURRENTLY` (lock risk), `ADD COLUMN NOT NULL` without `DEFAULT`, `ALTER COLUMN TYPE` (full table rewrite), `DROP COLUMN` (app breakage risk), `ADD CONSTRAINT` without `NOT VALID` (full table scan), `CREATE INDEX CONCURRENTLY` inside a transaction (runtime failure), `DROP TABLE`, `TRUNCATE`, `DELETE`/`UPDATE` without `WHERE`
106
106
  - Dynamic checks: connects to DB to verify referenced tables exist, estimates lock time based on actual row counts
107
107
  - CI-ready: `--ci` flag emits `::error::` / `::warning::` GitHub Actions annotations
108
108
 
@@ -113,7 +113,7 @@ The Dashboard is there when you need it. But the real power is in the CLI, MCP,
113
113
 
114
114
  ### 🔄 Multi-Env Diff
115
115
  - Compare schema and health between two PostgreSQL environments (local vs staging, staging vs prod)
116
- - Detects: missing/extra tables, missing/extra columns, column type mismatches, missing/extra indexes
116
+ - Detects: missing/extra tables, missing/extra columns, column type mismatches, missing/extra indexes, **foreign key and CHECK constraints**, **enum type differences**
117
117
  - `--health` flag adds health score comparison and unique issues per environment
118
118
  - `pg_dash_compare_env` MCP tool: ask your AI "what's different between local and staging?"
119
119
 
package/README.zh-CN.md CHANGED
@@ -102,7 +102,7 @@ Dashboard 需要时可以用。但真正的核心能力在 CLI、MCP 和 CI。
102
102
 
103
103
  ### 🛡️ Migration 安全检查
104
104
  - 执行迁移前分析 SQL 文件的风险
105
- - 检测:`CREATE INDEX`(无 `CONCURRENTLY` 会锁表)、`ADD COLUMN NOT NULL`(无 DEFAULT 会失败)、`DROP TABLE`、`TRUNCATE`、无 WHERE 的 `DELETE`/`UPDATE`
105
+ - 检测:`CREATE INDEX`(无 `CONCURRENTLY` 会锁表)、`ADD COLUMN NOT NULL`(无 DEFAULT 会失败)、`ALTER COLUMN TYPE`(全表重写)、`DROP COLUMN`(可能 break 代码)、`ADD CONSTRAINT` 无 `NOT VALID`(全表扫描验证)、`CREATE INDEX CONCURRENTLY` 在事务内(运行时必然失败)、`DROP TABLE`、`TRUNCATE`、无 WHERE 的 `DELETE`/`UPDATE`
106
106
  - 动态检查:连接数据库验证被引用表是否存在,根据实际行数估算锁表时间
107
107
  - CI 友好:`--ci` 输出 `::error::` / `::warning::` GitHub Actions 注解
108
108
 
@@ -113,7 +113,7 @@ Dashboard 需要时可以用。但真正的核心能力在 CLI、MCP 和 CI。
113
113
 
114
114
  ### 🔄 多环境对比
115
115
  - 对比两个 PostgreSQL 环境的 Schema 和健康状态(本地 vs 预发、预发 vs 生产)
116
- - 检测:缺失/多余的表、缺失/多余的列、列类型不匹配、缺失/多余的索引
116
+ - 检测:缺失/多余的表、缺失/多余的列、列类型不匹配、缺失/多余的索引、**外键和 CHECK 约束差异**、**枚举类型差异**
117
117
  - `--health` 参数额外对比健康分和各环境独有的问题
118
118
  - `pg_dash_compare_env` MCP 工具:直接问 AI "本地和预发有什么差异?"
119
119
 
package/dist/cli.js CHANGED
@@ -1381,6 +1381,33 @@ function staticCheck(sql) {
1381
1381
  tableName: table
1382
1382
  });
1383
1383
  }
1384
+ const renameTableRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)\s+RENAME\s+TO\s+(\w+)/gi;
1385
+ while ((m = renameTableRe.exec(sql)) !== null) {
1386
+ const oldName = m[1];
1387
+ const newName = m[2];
1388
+ issues.push({
1389
+ severity: "warning",
1390
+ code: "RENAME_TABLE",
1391
+ message: `Renaming table "${oldName}" to "${newName}" breaks application code referencing the old name`,
1392
+ suggestion: "Deploy application code that handles both names before renaming, or use a view with the old name after renaming.",
1393
+ lineNumber: findLineNumber(sql, m.index),
1394
+ tableName: oldName
1395
+ });
1396
+ }
1397
+ const renameColumnRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)\s+RENAME\s+COLUMN\s+(\w+)\s+TO\s+(\w+)/gi;
1398
+ while ((m = renameColumnRe.exec(sql)) !== null) {
1399
+ const table = m[1];
1400
+ const oldCol = m[2];
1401
+ const newCol = m[3];
1402
+ issues.push({
1403
+ severity: "warning",
1404
+ code: "RENAME_COLUMN",
1405
+ message: `Renaming column "${oldCol}" to "${newCol}" on table "${table}" breaks application code referencing the old column name`,
1406
+ suggestion: "Add new column, backfill data, update application to use new column, then drop old column (expand/contract pattern).",
1407
+ lineNumber: findLineNumber(sql, m.index),
1408
+ tableName: table
1409
+ });
1410
+ }
1384
1411
  const addConRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+ADD\s+CONSTRAINT\b[^;]*(;|$)/gi;
1385
1412
  while ((m = addConRe.exec(sql)) !== null) {
1386
1413
  const fragment = m[0];
@@ -1562,7 +1589,7 @@ async function fetchColumns(pool) {
1562
1589
  }
1563
1590
  async function fetchIndexes(pool) {
1564
1591
  const res = await pool.query(`
1565
- SELECT tablename, indexname
1592
+ SELECT tablename, indexname, indexdef
1566
1593
  FROM pg_indexes
1567
1594
  WHERE schemaname = 'public' AND indexname NOT LIKE '%_pkey'
1568
1595
  ORDER BY tablename, indexname
@@ -1603,6 +1630,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
1603
1630
  const missingColumns = [];
1604
1631
  const extraColumns = [];
1605
1632
  const typeDiffs = [];
1633
+ const nullableDiffs = [];
1634
+ const defaultDiffs = [];
1606
1635
  for (const [colName, srcInfo] of srcMap) {
1607
1636
  if (!tgtMap.has(colName)) {
1608
1637
  missingColumns.push(srcInfo);
@@ -1611,6 +1640,12 @@ function diffColumns(sourceCols, targetCols, commonTables) {
1611
1640
  if (srcInfo.type !== tgtInfo.type) {
1612
1641
  typeDiffs.push({ column: colName, sourceType: srcInfo.type, targetType: tgtInfo.type });
1613
1642
  }
1643
+ if (srcInfo.nullable !== tgtInfo.nullable) {
1644
+ nullableDiffs.push({ column: colName, sourceNullable: srcInfo.nullable, targetNullable: tgtInfo.nullable });
1645
+ }
1646
+ if ((srcInfo.default ?? null) !== (tgtInfo.default ?? null)) {
1647
+ defaultDiffs.push({ column: colName, sourceDefault: srcInfo.default ?? null, targetDefault: tgtInfo.default ?? null });
1648
+ }
1614
1649
  }
1615
1650
  }
1616
1651
  for (const [colName, tgtInfo] of tgtMap) {
@@ -1618,8 +1653,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
1618
1653
  extraColumns.push(tgtInfo);
1619
1654
  }
1620
1655
  }
1621
- if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0) {
1622
- diffs.push({ table, missingColumns, extraColumns, typeDiffs });
1656
+ if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0 || nullableDiffs.length > 0 || defaultDiffs.length > 0) {
1657
+ diffs.push({ table, missingColumns, extraColumns, typeDiffs, nullableDiffs, defaultDiffs });
1623
1658
  }
1624
1659
  }
1625
1660
  return diffs;
@@ -1627,8 +1662,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
1627
1662
  function groupIndexesByTable(indexes) {
1628
1663
  const map = /* @__PURE__ */ new Map();
1629
1664
  for (const idx of indexes) {
1630
- if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new Set());
1631
- map.get(idx.tablename).add(idx.indexname);
1665
+ if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new Map());
1666
+ map.get(idx.tablename).set(idx.indexname, idx.indexdef);
1632
1667
  }
1633
1668
  return map;
1634
1669
  }
@@ -1642,12 +1677,21 @@ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
1642
1677
  ]);
1643
1678
  for (const table of allTables) {
1644
1679
  if (!commonTables.includes(table)) continue;
1645
- const srcSet = srcByTable.get(table) ?? /* @__PURE__ */ new Set();
1646
- const tgtSet = tgtByTable.get(table) ?? /* @__PURE__ */ new Set();
1647
- const missingIndexes = [...srcSet].filter((i) => !tgtSet.has(i));
1648
- const extraIndexes = [...tgtSet].filter((i) => !srcSet.has(i));
1649
- if (missingIndexes.length > 0 || extraIndexes.length > 0) {
1650
- diffs.push({ table, missingIndexes, extraIndexes });
1680
+ const srcMap = srcByTable.get(table) ?? /* @__PURE__ */ new Map();
1681
+ const tgtMap = tgtByTable.get(table) ?? /* @__PURE__ */ new Map();
1682
+ const missingIndexes = [...srcMap.keys()].filter((i) => !tgtMap.has(i));
1683
+ const extraIndexes = [...tgtMap.keys()].filter((i) => !srcMap.has(i));
1684
+ const modifiedIndexes = [];
1685
+ for (const [name, srcDef] of srcMap) {
1686
+ if (tgtMap.has(name)) {
1687
+ const tgtDef = tgtMap.get(name);
1688
+ if (srcDef !== tgtDef) {
1689
+ modifiedIndexes.push({ name, sourceDef: srcDef, targetDef: tgtDef });
1690
+ }
1691
+ }
1692
+ }
1693
+ if (missingIndexes.length > 0 || extraIndexes.length > 0 || modifiedIndexes.length > 0) {
1694
+ diffs.push({ table, missingIndexes, extraIndexes, modifiedIndexes });
1651
1695
  }
1652
1696
  }
1653
1697
  return diffs;
@@ -1655,10 +1699,10 @@ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
1655
1699
  function countSchemaDrifts(schema) {
1656
1700
  let n = schema.missingTables.length + schema.extraTables.length;
1657
1701
  for (const cd of schema.columnDiffs) {
1658
- n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length;
1702
+ n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length + cd.nullableDiffs.length + cd.defaultDiffs.length;
1659
1703
  }
1660
1704
  for (const id of schema.indexDiffs) {
1661
- n += id.missingIndexes.length + id.extraIndexes.length;
1705
+ n += id.missingIndexes.length + id.extraIndexes.length + id.modifiedIndexes.length;
1662
1706
  }
1663
1707
  n += (schema.constraintDiffs ?? []).length;
1664
1708
  n += (schema.enumDiffs ?? []).length;
@@ -1795,8 +1839,31 @@ function formatTextDiff(result) {
1795
1839
  lines.push(` ~ column type differences:`);
1796
1840
  lines.push(...typeChanges);
1797
1841
  }
1842
+ const nullableChanges = [];
1843
+ const defaultChanges = [];
1844
+ for (const cd of schema.columnDiffs) {
1845
+ for (const nd of cd.nullableDiffs) {
1846
+ const src = nd.sourceNullable ? "nullable" : "NOT NULL";
1847
+ const tgt = nd.targetNullable ? "nullable" : "NOT NULL";
1848
+ nullableChanges.push(` ${cd.table}.${nd.column}: source=${src} \u2192 target=${tgt}`);
1849
+ }
1850
+ for (const dd of cd.defaultDiffs) {
1851
+ const src = dd.sourceDefault ?? "(none)";
1852
+ const tgt = dd.targetDefault ?? "(none)";
1853
+ defaultChanges.push(` ${cd.table}.${dd.column}: source=${src} \u2192 target=${tgt}`);
1854
+ }
1855
+ }
1856
+ if (nullableChanges.length > 0) {
1857
+ lines.push(` ~ nullable differences:`);
1858
+ lines.push(...nullableChanges);
1859
+ }
1860
+ if (defaultChanges.length > 0) {
1861
+ lines.push(` ~ default differences:`);
1862
+ lines.push(...defaultChanges);
1863
+ }
1798
1864
  const missingIdxs = [];
1799
1865
  const extraIdxs = [];
1866
+ const modifiedIdxs = [];
1800
1867
  for (const id of schema.indexDiffs) {
1801
1868
  for (const idx of id.missingIndexes) {
1802
1869
  missingIdxs.push(` ${id.table}: ${idx}`);
@@ -1804,6 +1871,9 @@ function formatTextDiff(result) {
1804
1871
  for (const idx of id.extraIndexes) {
1805
1872
  extraIdxs.push(` ${id.table}: ${idx}`);
1806
1873
  }
1874
+ for (const mi of id.modifiedIndexes) {
1875
+ modifiedIdxs.push(` ${id.table}: ${mi.name} source="${mi.sourceDef}" \u2192 target="${mi.targetDef}"`);
1876
+ }
1807
1877
  }
1808
1878
  if (missingIdxs.length > 0) {
1809
1879
  lines.push(` \u2717 target missing indexes:`);
@@ -1813,6 +1883,10 @@ function formatTextDiff(result) {
1813
1883
  lines.push(` \u26A0 target has extra indexes:`);
1814
1884
  lines.push(...extraIdxs);
1815
1885
  }
1886
+ if (modifiedIdxs.length > 0) {
1887
+ lines.push(` ~ index definition differences:`);
1888
+ lines.push(...modifiedIdxs);
1889
+ }
1816
1890
  const missingConstraints = (schema.constraintDiffs ?? []).filter((c) => c.type === "missing");
1817
1891
  const extraConstraints = (schema.constraintDiffs ?? []).filter((c) => c.type === "extra");
1818
1892
  const modifiedConstraints = (schema.constraintDiffs ?? []).filter((c) => c.type === "modified");
@@ -1849,7 +1923,7 @@ function formatTextDiff(result) {
1849
1923
  lines.push(` ~ enum differences:`);
1850
1924
  for (const e of modifiedEnums) lines.push(` ${e.detail}`);
1851
1925
  }
1852
- const noSchemaChanges = schema.missingTables.length === 0 && schema.extraTables.length === 0 && schema.columnDiffs.length === 0 && schema.indexDiffs.length === 0 && (schema.constraintDiffs ?? []).length === 0 && (schema.enumDiffs ?? []).length === 0;
1926
+ const noSchemaChanges = schema.missingTables.length === 0 && schema.extraTables.length === 0 && schema.columnDiffs.length === 0 && schema.indexDiffs.length === 0 && (schema.constraintDiffs ?? []).length === 0 && (schema.enumDiffs ?? []).length === 0 && nullableChanges.length === 0 && defaultChanges.length === 0 && modifiedIdxs.length === 0;
1853
1927
  if (noSchemaChanges) {
1854
1928
  lines.push(` \u2713 Schemas are identical`);
1855
1929
  }
@@ -1900,14 +1974,33 @@ function formatMdDiff(result) {
1900
1974
  if (missingColItems.length > 0) rows.push([`\u274C Missing columns`, missingColItems.join(", ")]);
1901
1975
  if (extraColItems.length > 0) rows.push([`\u26A0\uFE0F Extra columns`, extraColItems.join(", ")]);
1902
1976
  if (typeItems.length > 0) rows.push([`~ Type differences`, typeItems.join(", ")]);
1977
+ const nullableItems = [];
1978
+ const defaultItems = [];
1979
+ for (const cd of schema.columnDiffs) {
1980
+ for (const nd of cd.nullableDiffs) {
1981
+ const src = nd.sourceNullable ? "nullable" : "NOT NULL";
1982
+ const tgt = nd.targetNullable ? "nullable" : "NOT NULL";
1983
+ nullableItems.push(`\`${cd.table}.${nd.column}\` (${src}\u2192${tgt})`);
1984
+ }
1985
+ for (const dd of cd.defaultDiffs) {
1986
+ const src = dd.sourceDefault ?? "(none)";
1987
+ const tgt = dd.targetDefault ?? "(none)";
1988
+ defaultItems.push(`\`${cd.table}.${dd.column}\` (${src}\u2192${tgt})`);
1989
+ }
1990
+ }
1991
+ if (nullableItems.length > 0) rows.push([`~ Nullable differences`, nullableItems.join(", ")]);
1992
+ if (defaultItems.length > 0) rows.push([`~ Default differences`, defaultItems.join(", ")]);
1903
1993
  const missingIdxItems = [];
1904
1994
  const extraIdxItems = [];
1995
+ const modifiedIdxItems = [];
1905
1996
  for (const id of schema.indexDiffs) {
1906
1997
  for (const idx of id.missingIndexes) missingIdxItems.push(`\`${id.table}.${idx}\``);
1907
1998
  for (const idx of id.extraIndexes) extraIdxItems.push(`\`${id.table}.${idx}\``);
1999
+ for (const mi of id.modifiedIndexes) modifiedIdxItems.push(`\`${id.table}.${mi.name}\``);
1908
2000
  }
1909
2001
  if (missingIdxItems.length > 0) rows.push([`\u274C Missing indexes`, missingIdxItems.join(", ")]);
1910
2002
  if (extraIdxItems.length > 0) rows.push([`\u26A0\uFE0F Extra indexes`, extraIdxItems.join(", ")]);
2003
+ if (modifiedIdxItems.length > 0) rows.push([`~ Modified indexes`, modifiedIdxItems.join(", ")]);
1911
2004
  const missingConItems = (schema.constraintDiffs ?? []).filter((c) => c.type === "missing").map((c) => c.detail);
1912
2005
  const extraConItems = (schema.constraintDiffs ?? []).filter((c) => c.type === "extra").map((c) => c.detail);
1913
2006
  const modConItems = (schema.constraintDiffs ?? []).filter((c) => c.type === "modified").map((c) => c.detail);
@@ -3057,12 +3150,23 @@ async function analyzeExplainPlan(explainJson, pool) {
3057
3150
  if (pool) {
3058
3151
  existingIndexCols = await getExistingIndexColumns(pool, scan.table);
3059
3152
  }
3060
- for (const col of cols) {
3061
- const alreadyCovered = existingIndexCols.some(
3062
- (idxCols) => idxCols.length > 0 && idxCols[0] === col
3063
- );
3064
- if (alreadyCovered) continue;
3065
- const benefit = rateBenefit(scan.rowCount);
3153
+ const uncoveredCols = cols.filter(
3154
+ (col) => !existingIndexCols.some((idxCols) => idxCols.length > 0 && idxCols[0] === col)
3155
+ );
3156
+ if (uncoveredCols.length === 0) continue;
3157
+ const benefit = rateBenefit(scan.rowCount);
3158
+ if (uncoveredCols.length >= 2) {
3159
+ const idxName = `idx_${scan.table}_${uncoveredCols.join("_")}`;
3160
+ const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${uncoveredCols.join(", ")})`;
3161
+ result.missingIndexes.push({
3162
+ table: scan.table,
3163
+ columns: uncoveredCols,
3164
+ reason: `Seq Scan with multi-column filter (${uncoveredCols.join(", ")}) on ${fmtRows(scan.rowCount)} rows \u2014 composite index preferred`,
3165
+ sql,
3166
+ estimatedBenefit: benefit
3167
+ });
3168
+ } else {
3169
+ const col = uncoveredCols[0];
3066
3170
  const idxName = `idx_${scan.table}_${col}`;
3067
3171
  const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${col})`;
3068
3172
  result.missingIndexes.push({