@indiekitai/pg-dash 0.3.7 → 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/dist/mcp.js CHANGED
@@ -63,6 +63,30 @@ async function getTables(pool2) {
63
63
  }
64
64
 
65
65
  // src/server/queries/schema.ts
66
+ async function getSchemaTables(pool2) {
67
+ const client = await pool2.connect();
68
+ try {
69
+ const r = await client.query(`
70
+ SELECT
71
+ c.relname AS name,
72
+ n.nspname AS schema,
73
+ pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
74
+ pg_total_relation_size(c.oid) AS total_size_bytes,
75
+ pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
76
+ pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid)) AS index_size,
77
+ s.n_live_tup AS row_count,
78
+ obj_description(c.oid) AS description
79
+ FROM pg_class c
80
+ JOIN pg_namespace n ON c.relnamespace = n.oid
81
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
82
+ WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
83
+ ORDER BY pg_total_relation_size(c.oid) DESC
84
+ `);
85
+ return r.rows;
86
+ } finally {
87
+ client.release();
88
+ }
89
+ }
66
90
  async function getSchemaTableDetail(pool2, tableName) {
67
91
  const client = await pool2.connect();
68
92
  try {
@@ -160,6 +184,26 @@ async function getSchemaTableDetail(pool2, tableName) {
160
184
  client.release();
161
185
  }
162
186
  }
187
+ async function getSchemaEnums(pool2) {
188
+ const client = await pool2.connect();
189
+ try {
190
+ const r = await client.query(`
191
+ SELECT
192
+ t.typname AS name,
193
+ n.nspname AS schema,
194
+ array_agg(e.enumlabel ORDER BY e.enumsortorder) AS values
195
+ FROM pg_type t
196
+ JOIN pg_namespace n ON t.typnamespace = n.oid
197
+ JOIN pg_enum e ON t.oid = e.enumtypid
198
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
199
+ GROUP BY t.typname, n.nspname
200
+ ORDER BY t.typname
201
+ `);
202
+ return r.rows;
203
+ } finally {
204
+ client.release();
205
+ }
206
+ }
163
207
 
164
208
  // src/server/queries/activity.ts
165
209
  async function getActivity(pool2) {
@@ -989,6 +1033,140 @@ function diffSnapshots(prev, current) {
989
1033
 
990
1034
  // src/server/env-differ.ts
991
1035
  import { Pool } from "pg";
1036
+
1037
+ // src/server/schema-diff.ts
1038
+ function diffSnapshots2(oldSnap, newSnap) {
1039
+ const changes = [];
1040
+ const oldTableMap = new Map(oldSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
1041
+ const newTableMap = new Map(newSnap.tables.map((t) => [`${t.schema}.${t.name}`, t]));
1042
+ for (const [key, t] of newTableMap) {
1043
+ if (!oldTableMap.has(key)) {
1044
+ changes.push({ change_type: "added", object_type: "table", table_name: key, detail: `Table ${key} added` });
1045
+ }
1046
+ }
1047
+ for (const [key] of oldTableMap) {
1048
+ if (!newTableMap.has(key)) {
1049
+ changes.push({ change_type: "removed", object_type: "table", table_name: key, detail: `Table ${key} removed` });
1050
+ }
1051
+ }
1052
+ for (const [key, newTable] of newTableMap) {
1053
+ const oldTable = oldTableMap.get(key);
1054
+ if (!oldTable) continue;
1055
+ const oldCols = new Map(oldTable.columns.map((c) => [c.name, c]));
1056
+ const newCols = new Map(newTable.columns.map((c) => [c.name, c]));
1057
+ for (const [name, col] of newCols) {
1058
+ const oldCol = oldCols.get(name);
1059
+ if (!oldCol) {
1060
+ changes.push({ change_type: "added", object_type: "column", table_name: key, detail: `Column ${name} added (${col.type})` });
1061
+ } else {
1062
+ if (oldCol.type !== col.type) {
1063
+ changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} type changed: ${oldCol.type} \u2192 ${col.type}` });
1064
+ }
1065
+ if (oldCol.nullable !== col.nullable) {
1066
+ changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} nullable changed: ${oldCol.nullable} \u2192 ${col.nullable}` });
1067
+ }
1068
+ if (oldCol.default_value !== col.default_value) {
1069
+ changes.push({ change_type: "modified", object_type: "column", table_name: key, detail: `Column ${name} default changed: ${oldCol.default_value ?? "NULL"} \u2192 ${col.default_value ?? "NULL"}` });
1070
+ }
1071
+ }
1072
+ }
1073
+ for (const name of oldCols.keys()) {
1074
+ if (!newCols.has(name)) {
1075
+ changes.push({ change_type: "removed", object_type: "column", table_name: key, detail: `Column ${name} removed` });
1076
+ }
1077
+ }
1078
+ const oldIdx = new Map(oldTable.indexes.map((i) => [i.name, i]));
1079
+ const newIdx = new Map(newTable.indexes.map((i) => [i.name, i]));
1080
+ for (const [name, idx] of newIdx) {
1081
+ if (!oldIdx.has(name)) {
1082
+ changes.push({ change_type: "added", object_type: "index", table_name: key, detail: `Index ${name} added` });
1083
+ } else if (oldIdx.get(name).definition !== idx.definition) {
1084
+ changes.push({ change_type: "modified", object_type: "index", table_name: key, detail: `Index ${name} definition changed` });
1085
+ }
1086
+ }
1087
+ for (const name of oldIdx.keys()) {
1088
+ if (!newIdx.has(name)) {
1089
+ changes.push({ change_type: "removed", object_type: "index", table_name: key, detail: `Index ${name} removed` });
1090
+ }
1091
+ }
1092
+ const oldCon = new Map(oldTable.constraints.map((c) => [c.name, c]));
1093
+ const newCon = new Map(newTable.constraints.map((c) => [c.name, c]));
1094
+ for (const [name, con] of newCon) {
1095
+ if (!oldCon.has(name)) {
1096
+ changes.push({ change_type: "added", object_type: "constraint", table_name: key, detail: `Constraint ${name} added (${con.type})` });
1097
+ } else if (oldCon.get(name).definition !== con.definition) {
1098
+ changes.push({ change_type: "modified", object_type: "constraint", table_name: key, detail: `Constraint ${name} definition changed` });
1099
+ }
1100
+ }
1101
+ for (const name of oldCon.keys()) {
1102
+ if (!newCon.has(name)) {
1103
+ changes.push({ change_type: "removed", object_type: "constraint", table_name: key, detail: `Constraint ${name} removed` });
1104
+ }
1105
+ }
1106
+ }
1107
+ const oldEnums = new Map((oldSnap.enums || []).map((e) => [`${e.schema}.${e.name}`, e]));
1108
+ const newEnums = new Map((newSnap.enums || []).map((e) => [`${e.schema}.${e.name}`, e]));
1109
+ for (const [key, en] of newEnums) {
1110
+ const oldEn = oldEnums.get(key);
1111
+ if (!oldEn) {
1112
+ changes.push({ change_type: "added", object_type: "enum", table_name: null, detail: `Enum ${key} added (${en.values.join(", ")})` });
1113
+ } else {
1114
+ const added = en.values.filter((v) => !oldEn.values.includes(v));
1115
+ const removed = oldEn.values.filter((v) => !en.values.includes(v));
1116
+ for (const v of added) {
1117
+ changes.push({ change_type: "modified", object_type: "enum", table_name: null, detail: `Enum ${key}: value '${v}' added` });
1118
+ }
1119
+ for (const v of removed) {
1120
+ changes.push({ change_type: "modified", object_type: "enum", table_name: null, detail: `Enum ${key}: value '${v}' removed` });
1121
+ }
1122
+ }
1123
+ }
1124
+ for (const key of oldEnums.keys()) {
1125
+ if (!newEnums.has(key)) {
1126
+ changes.push({ change_type: "removed", object_type: "enum", table_name: null, detail: `Enum ${key} removed` });
1127
+ }
1128
+ }
1129
+ return changes;
1130
+ }
1131
+
1132
+ // src/server/schema-tracker.ts
1133
+ async function buildLiveSnapshot(pool2) {
1134
+ const tables = await getSchemaTables(pool2);
1135
+ const enums = await getSchemaEnums(pool2);
1136
+ const detailedTables = await Promise.all(
1137
+ tables.map(async (t) => {
1138
+ const detail = await getSchemaTableDetail(pool2, `${t.schema}.${t.name}`);
1139
+ if (!detail) return null;
1140
+ return {
1141
+ name: detail.name,
1142
+ schema: detail.schema,
1143
+ columns: detail.columns.map((c) => ({
1144
+ name: c.name,
1145
+ type: c.type,
1146
+ nullable: c.nullable,
1147
+ default_value: c.default_value
1148
+ })),
1149
+ indexes: detail.indexes.map((i) => ({
1150
+ name: i.name,
1151
+ definition: i.definition,
1152
+ is_unique: i.is_unique,
1153
+ is_primary: i.is_primary
1154
+ })),
1155
+ constraints: detail.constraints.map((c) => ({
1156
+ name: c.name,
1157
+ type: c.type,
1158
+ definition: c.definition
1159
+ }))
1160
+ };
1161
+ })
1162
+ );
1163
+ return {
1164
+ tables: detailedTables.filter(Boolean),
1165
+ enums: enums.map((e) => ({ name: e.name, schema: e.schema, values: e.values }))
1166
+ };
1167
+ }
1168
+
1169
+ // src/server/env-differ.ts
992
1170
  async function fetchTables(pool2) {
993
1171
  const res = await pool2.query(`
994
1172
  SELECT table_name
@@ -1009,7 +1187,7 @@ async function fetchColumns(pool2) {
1009
1187
  }
1010
1188
  async function fetchIndexes(pool2) {
1011
1189
  const res = await pool2.query(`
1012
- SELECT tablename, indexname
1190
+ SELECT tablename, indexname, indexdef
1013
1191
  FROM pg_indexes
1014
1192
  WHERE schemaname = 'public' AND indexname NOT LIKE '%_pkey'
1015
1193
  ORDER BY tablename, indexname
@@ -1050,6 +1228,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
1050
1228
  const missingColumns = [];
1051
1229
  const extraColumns = [];
1052
1230
  const typeDiffs = [];
1231
+ const nullableDiffs = [];
1232
+ const defaultDiffs = [];
1053
1233
  for (const [colName, srcInfo] of srcMap) {
1054
1234
  if (!tgtMap.has(colName)) {
1055
1235
  missingColumns.push(srcInfo);
@@ -1058,6 +1238,12 @@ function diffColumns(sourceCols, targetCols, commonTables) {
1058
1238
  if (srcInfo.type !== tgtInfo.type) {
1059
1239
  typeDiffs.push({ column: colName, sourceType: srcInfo.type, targetType: tgtInfo.type });
1060
1240
  }
1241
+ if (srcInfo.nullable !== tgtInfo.nullable) {
1242
+ nullableDiffs.push({ column: colName, sourceNullable: srcInfo.nullable, targetNullable: tgtInfo.nullable });
1243
+ }
1244
+ if ((srcInfo.default ?? null) !== (tgtInfo.default ?? null)) {
1245
+ defaultDiffs.push({ column: colName, sourceDefault: srcInfo.default ?? null, targetDefault: tgtInfo.default ?? null });
1246
+ }
1061
1247
  }
1062
1248
  }
1063
1249
  for (const [colName, tgtInfo] of tgtMap) {
@@ -1065,8 +1251,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
1065
1251
  extraColumns.push(tgtInfo);
1066
1252
  }
1067
1253
  }
1068
- if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0) {
1069
- diffs.push({ table, missingColumns, extraColumns, typeDiffs });
1254
+ if (missingColumns.length > 0 || extraColumns.length > 0 || typeDiffs.length > 0 || nullableDiffs.length > 0 || defaultDiffs.length > 0) {
1255
+ diffs.push({ table, missingColumns, extraColumns, typeDiffs, nullableDiffs, defaultDiffs });
1070
1256
  }
1071
1257
  }
1072
1258
  return diffs;
@@ -1074,8 +1260,8 @@ function diffColumns(sourceCols, targetCols, commonTables) {
1074
1260
  function groupIndexesByTable(indexes) {
1075
1261
  const map = /* @__PURE__ */ new Map();
1076
1262
  for (const idx of indexes) {
1077
- if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new Set());
1078
- map.get(idx.tablename).add(idx.indexname);
1263
+ if (!map.has(idx.tablename)) map.set(idx.tablename, /* @__PURE__ */ new Map());
1264
+ map.get(idx.tablename).set(idx.indexname, idx.indexdef);
1079
1265
  }
1080
1266
  return map;
1081
1267
  }
@@ -1089,12 +1275,21 @@ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
1089
1275
  ]);
1090
1276
  for (const table of allTables) {
1091
1277
  if (!commonTables.includes(table)) continue;
1092
- const srcSet = srcByTable.get(table) ?? /* @__PURE__ */ new Set();
1093
- const tgtSet = tgtByTable.get(table) ?? /* @__PURE__ */ new Set();
1094
- const missingIndexes = [...srcSet].filter((i) => !tgtSet.has(i));
1095
- const extraIndexes = [...tgtSet].filter((i) => !srcSet.has(i));
1096
- if (missingIndexes.length > 0 || extraIndexes.length > 0) {
1097
- diffs.push({ table, missingIndexes, extraIndexes });
1278
+ const srcMap = srcByTable.get(table) ?? /* @__PURE__ */ new Map();
1279
+ const tgtMap = tgtByTable.get(table) ?? /* @__PURE__ */ new Map();
1280
+ const missingIndexes = [...srcMap.keys()].filter((i) => !tgtMap.has(i));
1281
+ const extraIndexes = [...tgtMap.keys()].filter((i) => !srcMap.has(i));
1282
+ const modifiedIndexes = [];
1283
+ for (const [name, srcDef] of srcMap) {
1284
+ if (tgtMap.has(name)) {
1285
+ const tgtDef = tgtMap.get(name);
1286
+ if (srcDef !== tgtDef) {
1287
+ modifiedIndexes.push({ name, sourceDef: srcDef, targetDef: tgtDef });
1288
+ }
1289
+ }
1290
+ }
1291
+ if (missingIndexes.length > 0 || extraIndexes.length > 0 || modifiedIndexes.length > 0) {
1292
+ diffs.push({ table, missingIndexes, extraIndexes, modifiedIndexes });
1098
1293
  }
1099
1294
  }
1100
1295
  return diffs;
@@ -1102,11 +1297,13 @@ function diffIndexes(sourceIdxs, targetIdxs, commonTables) {
1102
1297
  function countSchemaDrifts(schema) {
1103
1298
  let n = schema.missingTables.length + schema.extraTables.length;
1104
1299
  for (const cd of schema.columnDiffs) {
1105
- n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length;
1300
+ n += cd.missingColumns.length + cd.extraColumns.length + cd.typeDiffs.length + cd.nullableDiffs.length + cd.defaultDiffs.length;
1106
1301
  }
1107
1302
  for (const id of schema.indexDiffs) {
1108
- n += id.missingIndexes.length + id.extraIndexes.length;
1303
+ n += id.missingIndexes.length + id.extraIndexes.length + id.modifiedIndexes.length;
1109
1304
  }
1305
+ n += (schema.constraintDiffs ?? []).length;
1306
+ n += (schema.enumDiffs ?? []).length;
1110
1307
  return n;
1111
1308
  }
1112
1309
  async function diffEnvironments(sourceConn, targetConn, options) {
@@ -1119,22 +1316,46 @@ async function diffEnvironments(sourceConn, targetConn, options) {
1119
1316
  sourceCols,
1120
1317
  targetCols,
1121
1318
  sourceIdxs,
1122
- targetIdxs
1319
+ targetIdxs,
1320
+ sourceSnap,
1321
+ targetSnap
1123
1322
  ] = await Promise.all([
1124
1323
  fetchTables(sourcePool),
1125
1324
  fetchTables(targetPool),
1126
1325
  fetchColumns(sourcePool),
1127
1326
  fetchColumns(targetPool),
1128
1327
  fetchIndexes(sourcePool),
1129
- fetchIndexes(targetPool)
1328
+ fetchIndexes(targetPool),
1329
+ buildLiveSnapshot(sourcePool).catch(() => null),
1330
+ buildLiveSnapshot(targetPool).catch(() => null)
1130
1331
  ]);
1131
1332
  const { missingTables, extraTables } = diffTables(sourceTables, targetTables);
1132
- const sourceSet = new Set(sourceTables);
1133
1333
  const targetSet = new Set(targetTables);
1134
1334
  const commonTables = sourceTables.filter((t) => targetSet.has(t));
1135
1335
  const columnDiffs = diffColumns(sourceCols, targetCols, commonTables);
1136
1336
  const indexDiffs = diffIndexes(sourceIdxs, targetIdxs, commonTables);
1137
- const schema = { missingTables, extraTables, columnDiffs, indexDiffs };
1337
+ const constraintDiffs = [];
1338
+ const enumDiffs = [];
1339
+ if (sourceSnap && targetSnap) {
1340
+ const snapChanges = diffSnapshots2(sourceSnap, targetSnap);
1341
+ for (const c of snapChanges) {
1342
+ if (c.object_type === "constraint") {
1343
+ constraintDiffs.push({
1344
+ table: c.table_name ?? null,
1345
+ type: c.change_type === "added" ? "extra" : c.change_type === "removed" ? "missing" : "modified",
1346
+ name: c.detail.split(" ")[1] ?? c.detail,
1347
+ detail: c.detail
1348
+ });
1349
+ } else if (c.object_type === "enum") {
1350
+ enumDiffs.push({
1351
+ type: c.change_type === "added" ? "extra" : c.change_type === "removed" ? "missing" : "modified",
1352
+ name: c.detail.split(" ")[1] ?? c.detail,
1353
+ detail: c.detail
1354
+ });
1355
+ }
1356
+ }
1357
+ }
1358
+ const schema = { missingTables, extraTables, columnDiffs, indexDiffs, constraintDiffs, enumDiffs };
1138
1359
  const schemaDrifts = countSchemaDrifts(schema);
1139
1360
  let health;
1140
1361
  if (options?.includeHealth) {
@@ -1276,12 +1497,23 @@ async function analyzeExplainPlan(explainJson, pool2) {
1276
1497
  if (pool2) {
1277
1498
  existingIndexCols = await getExistingIndexColumns(pool2, scan.table);
1278
1499
  }
1279
- for (const col of cols) {
1280
- const alreadyCovered = existingIndexCols.some(
1281
- (idxCols) => idxCols.length > 0 && idxCols[0] === col
1282
- );
1283
- if (alreadyCovered) continue;
1284
- const benefit = rateBenefit(scan.rowCount);
1500
+ const uncoveredCols = cols.filter(
1501
+ (col) => !existingIndexCols.some((idxCols) => idxCols.length > 0 && idxCols[0] === col)
1502
+ );
1503
+ if (uncoveredCols.length === 0) continue;
1504
+ const benefit = rateBenefit(scan.rowCount);
1505
+ if (uncoveredCols.length >= 2) {
1506
+ const idxName = `idx_${scan.table}_${uncoveredCols.join("_")}`;
1507
+ const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${uncoveredCols.join(", ")})`;
1508
+ result.missingIndexes.push({
1509
+ table: scan.table,
1510
+ columns: uncoveredCols,
1511
+ reason: `Seq Scan with multi-column filter (${uncoveredCols.join(", ")}) on ${fmtRows(scan.rowCount)} rows \u2014 composite index preferred`,
1512
+ sql,
1513
+ estimatedBenefit: benefit
1514
+ });
1515
+ } else {
1516
+ const col = uncoveredCols[0];
1285
1517
  const idxName = `idx_${scan.table}_${col}`;
1286
1518
  const sql = `CREATE INDEX CONCURRENTLY ${idxName} ON ${scan.table} (${col})`;
1287
1519
  result.missingIndexes.push({
@@ -1471,6 +1703,83 @@ function staticCheck(sql) {
1471
1703
  lineNumber: findLineNumber(sql, m.index)
1472
1704
  });
1473
1705
  }
1706
+ const alterTypeRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+ALTER\s+(?:COLUMN\s+)?[\w"]+\s+TYPE\b/gi;
1707
+ while ((m = alterTypeRe.exec(sql)) !== null) {
1708
+ const table = bareTable(m[1]);
1709
+ issues.push({
1710
+ severity: "warning",
1711
+ code: "ALTER_COLUMN_TYPE",
1712
+ message: "ALTER COLUMN TYPE rewrites the entire table and acquires an exclusive lock.",
1713
+ suggestion: "Consider using a new column + backfill + rename strategy to avoid downtime.",
1714
+ lineNumber: findLineNumber(sql, m.index),
1715
+ tableName: table
1716
+ });
1717
+ }
1718
+ const dropColRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+DROP\s+(?:COLUMN\s+)(?:IF\s+EXISTS\s+)?[\w"]+\b/gi;
1719
+ while ((m = dropColRe.exec(sql)) !== null) {
1720
+ const table = bareTable(m[1]);
1721
+ issues.push({
1722
+ severity: "info",
1723
+ code: "DROP_COLUMN",
1724
+ message: "DROP COLUMN is safe in PostgreSQL (no table rewrite), but may break application code referencing that column.",
1725
+ suggestion: "Ensure no application code references this column before dropping it.",
1726
+ lineNumber: findLineNumber(sql, m.index),
1727
+ tableName: table
1728
+ });
1729
+ }
1730
+ const renameTableRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)\s+RENAME\s+TO\s+(\w+)/gi;
1731
+ while ((m = renameTableRe.exec(sql)) !== null) {
1732
+ const oldName = m[1];
1733
+ const newName = m[2];
1734
+ issues.push({
1735
+ severity: "warning",
1736
+ code: "RENAME_TABLE",
1737
+ message: `Renaming table "${oldName}" to "${newName}" breaks application code referencing the old name`,
1738
+ suggestion: "Deploy application code that handles both names before renaming, or use a view with the old name after renaming.",
1739
+ lineNumber: findLineNumber(sql, m.index),
1740
+ tableName: oldName
1741
+ });
1742
+ }
1743
+ const renameColumnRe = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)\s+RENAME\s+COLUMN\s+(\w+)\s+TO\s+(\w+)/gi;
1744
+ while ((m = renameColumnRe.exec(sql)) !== null) {
1745
+ const table = m[1];
1746
+ const oldCol = m[2];
1747
+ const newCol = m[3];
1748
+ issues.push({
1749
+ severity: "warning",
1750
+ code: "RENAME_COLUMN",
1751
+ message: `Renaming column "${oldCol}" to "${newCol}" on table "${table}" breaks application code referencing the old column name`,
1752
+ suggestion: "Add new column, backfill data, update application to use new column, then drop old column (expand/contract pattern).",
1753
+ lineNumber: findLineNumber(sql, m.index),
1754
+ tableName: table
1755
+ });
1756
+ }
1757
+ const addConRe = /\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w."]+)\s+ADD\s+CONSTRAINT\b[^;]*(;|$)/gi;
1758
+ while ((m = addConRe.exec(sql)) !== null) {
1759
+ const fragment = m[0];
1760
+ const table = bareTable(m[1]);
1761
+ const fragUpper = fragment.toUpperCase();
1762
+ if (!/\bNOT\s+VALID\b/.test(fragUpper)) {
1763
+ issues.push({
1764
+ severity: "warning",
1765
+ code: "ADD_CONSTRAINT_SCANS_TABLE",
1766
+ message: "ADD CONSTRAINT validates all existing rows and holds an exclusive lock during the scan.",
1767
+ suggestion: "Use ADD CONSTRAINT ... NOT VALID to skip validation, then VALIDATE CONSTRAINT in a separate transaction.",
1768
+ lineNumber: findLineNumber(sql, m.index),
1769
+ tableName: table
1770
+ });
1771
+ }
1772
+ }
1773
+ const hasTransaction = /\bBEGIN\b/i.test(sql) || /\bSTART\s+TRANSACTION\b/i.test(sql);
1774
+ const hasConcurrently = /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\b/i.test(sql);
1775
+ if (hasTransaction && hasConcurrently) {
1776
+ issues.push({
1777
+ severity: "error",
1778
+ code: "CONCURRENTLY_IN_TRANSACTION",
1779
+ message: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block. It will fail at runtime.",
1780
+ suggestion: "Remove the BEGIN/COMMIT wrapper, or use a migration tool that runs CONCURRENTLY outside transactions."
1781
+ });
1782
+ }
1474
1783
  const truncRe = /\bTRUNCATE\b/gi;
1475
1784
  while ((m = truncRe.exec(sql)) !== null) {
1476
1785
  issues.push({