validate-migrations.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. #!/usr/bin/env node
  2. /**
  3. * 迁移文件幂等性校验脚本
  4. *
  5. * 检查所有 Drizzle 迁移文件,确保:
  6. * 1. CREATE TABLE 使用 IF NOT EXISTS
  7. * 2. CREATE INDEX 使用 IF NOT EXISTS
  8. *
  9. * 防止迁移在重复执行或数据库状态不一致时失败
  10. */
  11. import fs from "node:fs";
  12. import path from "node:path";
  13. import { fileURLToPath } from "node:url";
  14. const __filename = fileURLToPath(import.meta.url);
  15. const __dirname = path.dirname(__filename);
  16. // 迁移文件目录
  17. const MIGRATIONS_DIR = path.join(__dirname, "../drizzle");
  18. // 豁免列表:历史迁移文件(在问题修复前已发布的版本)
  19. // 这些文件虽然缺少 IF NOT EXISTS,但已在生产环境稳定运行
  20. // 不建议修改以避免引入新问题
  21. const EXEMPT_FILES = [
  22. "0000_legal_brother_voodoo.sql", // 初始迁移
  23. "0001_ambiguous_bromley.sql", // 历史迁移
  24. "0002_fancy_preak.sql", // 历史迁移
  25. "0003_outstanding_centennial.sql", // 历史迁移
  26. "0004_dazzling_starbolt.sql", // 历史迁移
  27. "0005_true_raza.sql", // 历史迁移
  28. "0006_lame_matthew_murdock.sql", // 历史迁移
  29. "0007_lazy_post.sql", // 历史迁移
  30. "0008_talented_molten_man.sql", // 历史迁移
  31. "0009_many_amazoness.sql", // 历史迁移
  32. "0010_unusual_bloodscream.sql", // 历史迁移
  33. "0011_charming_ben_parker.sql", // 历史迁移
  34. "0012_elite_iron_patriot.sql", // 历史迁移
  35. "0014_overconfident_mongu.sql", // 历史迁移(0013 已修复)
  36. ];
  37. // 颜色输出
  38. const colors = {
  39. reset: "\x1b[0m",
  40. red: "\x1b[31m",
  41. green: "\x1b[32m",
  42. yellow: "\x1b[33m",
  43. blue: "\x1b[34m",
  44. };
  45. function log(color, prefix, message) {
  46. console.log(`${color}${prefix}${colors.reset} ${message}`);
  47. }
  48. function info(message) {
  49. log(colors.blue, "[INFO]", message);
  50. }
  51. function warn(message) {
  52. log(colors.yellow, "[WARN]", message);
  53. }
  54. function error(message) {
  55. log(colors.red, "[ERROR]", message);
  56. }
  57. function success(message) {
  58. log(colors.green, "[SUCCESS]", message);
  59. }
  60. /**
  61. * 检查 SQL 文件的幂等性
  62. */
  63. function validateMigrationFile(filePath) {
  64. const fileName = path.basename(filePath);
  65. const content = fs.readFileSync(filePath, "utf-8");
  66. const lines = content.split("\n");
  67. const issues = [];
  68. // 检查 CREATE TABLE 语句
  69. const createTableRegex = /CREATE\s+TABLE\s+"[^"]+"/gi;
  70. const createTableIfNotExistsRegex = /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+"[^"]+"/gi;
  71. const createTables = content.match(createTableRegex) || [];
  72. const createTablesIfNotExists = content.match(createTableIfNotExistsRegex) || [];
  73. const missingIfNotExistsTables = createTables.length - createTablesIfNotExists.length;
  74. if (missingIfNotExistsTables > 0) {
  75. createTables.forEach((match) => {
  76. if (!/IF\s+NOT\s+EXISTS/i.test(match)) {
  77. const lineNumber = lines.findIndex((line) => line.includes(match.split('"')[1])) + 1;
  78. issues.push({
  79. type: "CREATE TABLE",
  80. line: lineNumber,
  81. statement: match,
  82. suggestion: match.replace(/CREATE\s+TABLE\s+/i, "CREATE TABLE IF NOT EXISTS "),
  83. });
  84. }
  85. });
  86. }
  87. // 检查 CREATE INDEX 语句
  88. const createIndexRegex = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+"[^"]+"/gi;
  89. const createIndexIfNotExistsRegex =
  90. /CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+"[^"]+"/gi;
  91. const createIndexes = content.match(createIndexRegex) || [];
  92. const createIndexesIfNotExists = content.match(createIndexIfNotExistsRegex) || [];
  93. const missingIfNotExistsIndexes = createIndexes.length - createIndexesIfNotExists.length;
  94. if (missingIfNotExistsIndexes > 0) {
  95. createIndexes.forEach((match) => {
  96. if (!/IF\s+NOT\s+EXISTS/i.test(match)) {
  97. const lineNumber = lines.findIndex((line) => line.includes(match)) + 1;
  98. issues.push({
  99. type: "CREATE INDEX",
  100. line: lineNumber,
  101. statement: match,
  102. suggestion: match.replace(
  103. /CREATE\s+(UNIQUE\s+)?INDEX\s+/i,
  104. "CREATE $1INDEX IF NOT EXISTS "
  105. ),
  106. });
  107. }
  108. });
  109. }
  110. return { fileName, issues };
  111. }
  112. /**
  113. * 校验 Drizzle journal 的时间戳单调性
  114. *
  115. * Drizzle PG migrator 仅通过 `created_at(folderMillis)` 与 DB 中最新一条迁移记录做比较来决定是否执行迁移:
  116. * - 若 journal 中 `when` 非严格递增,可能导致“后续迁移被永久跳过”(无感升级会漏执行)
  117. */
  118. function validateJournalMonotonicity(journalPath) {
  119. const content = fs.readFileSync(journalPath, "utf-8");
  120. const journal = JSON.parse(content);
  121. if (!journal || !Array.isArray(journal.entries)) {
  122. return {
  123. fileName: path.basename(journalPath),
  124. issues: [
  125. {
  126. type: "JOURNAL",
  127. line: 0,
  128. statement: "Invalid journal format: entries[] is missing",
  129. suggestion: "Ensure drizzle/meta/_journal.json contains a valid { entries: [...] }",
  130. },
  131. ],
  132. };
  133. }
  134. const issues = [];
  135. let previousWhen = Number.NEGATIVE_INFINITY;
  136. let previousTag = "";
  137. for (const entry of journal.entries) {
  138. const tag = typeof entry?.tag === "string" ? entry.tag : "(unknown)";
  139. const when = entry?.when;
  140. if (typeof when !== "number" || !Number.isFinite(when)) {
  141. issues.push({
  142. type: "JOURNAL",
  143. line: 0,
  144. statement: `Invalid journal entry 'when' for tag=${tag}`,
  145. suggestion: "Ensure each journal entry has a numeric 'when' (folderMillis).",
  146. });
  147. continue;
  148. }
  149. if (when <= previousWhen) {
  150. issues.push({
  151. type: "JOURNAL",
  152. line: 0,
  153. statement: `Non-monotonic journal 'when': ${tag}(${when}) <= ${previousTag}(${previousWhen})`,
  154. suggestion: "Ensure journal entries' 'when' are strictly increasing in execution order.",
  155. });
  156. }
  157. previousWhen = when;
  158. previousTag = tag;
  159. }
  160. return { fileName: path.basename(journalPath), issues };
  161. }
  162. /**
  163. * 主函数
  164. */
  165. function main() {
  166. info("开始检查迁移文件的幂等性...\n");
  167. // 获取所有 .sql 迁移文件
  168. const files = fs
  169. .readdirSync(MIGRATIONS_DIR)
  170. .filter((file) => file.endsWith(".sql") && /^\d{4}_/.test(file))
  171. .sort()
  172. .map((file) => path.join(MIGRATIONS_DIR, file));
  173. if (files.length === 0) {
  174. warn("未找到任何迁移文件");
  175. process.exit(0);
  176. }
  177. info(`找到 ${files.length} 个迁移文件\n`);
  178. let totalIssues = 0;
  179. const filesWithIssues = [];
  180. // 校验 meta/_journal.json 的单调性(避免漏迁移)
  181. const journalPath = path.join(MIGRATIONS_DIR, "meta/_journal.json");
  182. if (fs.existsSync(journalPath)) {
  183. const journalResult = validateJournalMonotonicity(journalPath);
  184. if (journalResult.issues.length > 0) {
  185. totalIssues += journalResult.issues.length;
  186. filesWithIssues.push(journalResult);
  187. error(`${journalResult.fileName} - 发现 ${journalResult.issues.length} 个问题:`);
  188. journalResult.issues.forEach((issue, index) => {
  189. console.log(`\n ${index + 1}. ${issue.type}`);
  190. console.log(` ${colors.red}✗${colors.reset} ${issue.statement}`);
  191. console.log(` ${colors.green}✓${colors.reset} ${issue.suggestion}`);
  192. });
  193. console.log("");
  194. }
  195. } else {
  196. warn("未找到 meta/_journal.json,无法校验迁移顺序与时间戳单调性");
  197. }
  198. // 检查每个文件
  199. files.forEach((filePath) => {
  200. const result = validateMigrationFile(filePath);
  201. const isExempt = EXEMPT_FILES.includes(result.fileName);
  202. if (result.issues.length > 0) {
  203. if (isExempt) {
  204. // 豁免文件:只显示警告,不计入失败
  205. warn(`${result.fileName} - 发现 ${result.issues.length} 个问题(已豁免)`);
  206. return;
  207. }
  208. totalIssues += result.issues.length;
  209. filesWithIssues.push(result);
  210. error(`${result.fileName} - 发现 ${result.issues.length} 个问题:`);
  211. result.issues.forEach((issue, index) => {
  212. console.log(`\n ${index + 1}. ${issue.type} (第 ${issue.line} 行)`);
  213. console.log(` ${colors.red}✗${colors.reset} ${issue.statement}`);
  214. console.log(` ${colors.green}✓${colors.reset} ${issue.suggestion}`);
  215. });
  216. console.log("");
  217. }
  218. });
  219. // 输出总结
  220. console.log("─".repeat(60));
  221. if (totalIssues === 0) {
  222. success(`所有 ${files.length} 个迁移文件都通过了幂等性检查 ✓`);
  223. process.exit(0);
  224. } else {
  225. error(`检查完成: 发现 ${totalIssues} 个问题,涉及 ${filesWithIssues.length} 个文件`);
  226. console.log("");
  227. warn("建议修复上述问题以确保迁移的幂等性");
  228. warn("所有 CREATE TABLE 和 CREATE INDEX 语句都应该使用 IF NOT EXISTS");
  229. process.exit(1);
  230. }
  231. }
  232. // 执行
  233. try {
  234. main();
  235. } catch (err) {
  236. error(`脚本执行失败: ${err.message}`);
  237. console.error(err);
  238. process.exit(1);
  239. }