befly 3.76.0 → 3.76.2

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.
Files changed (155) hide show
  1. package/Befly.js +159 -0
  2. package/README.md +50 -11
  3. package/apis/admin/delete.js +1 -3
  4. package/apis/admin/insert.js +2 -1
  5. package/apis/admin/select.js +2 -7
  6. package/apis/admin/update.js +3 -8
  7. package/apis/api/select.js +2 -7
  8. package/apis/auth/login.js +16 -20
  9. package/apis/dict/delete.js +1 -1
  10. package/apis/dict/detail.js +1 -3
  11. package/apis/dict/select.js +1 -2
  12. package/apis/dict/update.js +1 -1
  13. package/apis/dictType/delete.js +1 -1
  14. package/apis/dictType/detail.js +1 -1
  15. package/apis/dictType/insert.js +1 -1
  16. package/apis/dictType/select.js +2 -7
  17. package/apis/dictType/update.js +2 -2
  18. package/apis/email/config.js +4 -7
  19. package/apis/email/logList.js +2 -7
  20. package/apis/email/send.js +1 -2
  21. package/apis/email/verify.js +1 -3
  22. package/apis/loginLog/select.js +2 -7
  23. package/apis/menu/select.js +2 -7
  24. package/apis/operateLog/select.js +2 -7
  25. package/apis/role/apiSave.js +2 -2
  26. package/apis/role/menuSave.js +2 -2
  27. package/apis/role/select.js +2 -7
  28. package/apis/source/imageList.js +2 -5
  29. package/apis/tongJi/dailyReport.js +12 -13
  30. package/apis/tongJi/dailyReportSelect.js +1 -2
  31. package/apis/tongJi/dailyStats.js +1 -1
  32. package/apis/tongJi/dailyStatsDistribution.js +2 -2
  33. package/apis/tongJi/errorList.js +1 -2
  34. package/apis/tongJi/errorReport.js +1 -2
  35. package/apis/tongJi/projectDelete.js +1 -3
  36. package/apis/tongJi/projectSelect.js +2 -4
  37. package/apis/tongJi/projectUpdate.js +2 -2
  38. package/apis/upload/file.js +7 -5
  39. package/checks/api.js +43 -66
  40. package/checks/config.js +17 -12
  41. package/checks/corns.js +28 -46
  42. package/checks/field.js +18 -0
  43. package/checks/items.js +16 -60
  44. package/checks/menu.js +28 -39
  45. package/checks/table.js +106 -26
  46. package/checks/validation.js +13 -0
  47. package/configs/beflyConfig.json +6 -6
  48. package/configs/constConfig.js +2 -63
  49. package/exports.js +2 -0
  50. package/hooks/auth.js +11 -7
  51. package/hooks/parser.js +55 -59
  52. package/hooks/permission.js +5 -2
  53. package/hooks/rateLimit.js +25 -62
  54. package/hooks/validator.js +43 -31
  55. package/index.js +43 -344
  56. package/{lib → libs}/cacheHelper.js +3 -8
  57. package/libs/cron.js +60 -0
  58. package/libs/logger/index.js +1 -0
  59. package/libs/logger/logger.js +245 -0
  60. package/libs/logger/sanitize.js +115 -0
  61. package/{lib → libs/mysql}/dbHelper.js +100 -73
  62. package/{lib → libs/mysql}/dbParse.js +16 -9
  63. package/{lib → libs/mysql}/dbUtil.js +15 -32
  64. package/libs/mysql/error.js +29 -0
  65. package/libs/mysql/index.js +6 -0
  66. package/libs/mysql/is.js +29 -0
  67. package/libs/mysql/mysql.js +43 -0
  68. package/libs/mysql/sql/identifier.js +85 -0
  69. package/libs/mysql/sql/index.js +2 -0
  70. package/{lib → libs/mysql/sql}/sqlBuilder.js +2 -3
  71. package/libs/mysql/util.js +44 -0
  72. package/libs/redis/index.js +2 -0
  73. package/libs/redis/redis.js +204 -0
  74. package/libs/redis/redisError.js +8 -0
  75. package/libs/tableModel.js +72 -0
  76. package/libs/tool.js +33 -0
  77. package/libs/validator/compiler.js +248 -0
  78. package/libs/validator/index.js +10 -0
  79. package/libs/validator/parser.js +173 -0
  80. package/libs/validator/util.js +5 -0
  81. package/package.json +10 -8
  82. package/paths.js +2 -3
  83. package/plugins/cache.js +2 -1
  84. package/plugins/email.js +4 -4
  85. package/plugins/mysql.js +5 -15
  86. package/plugins/redis.js +4 -12
  87. package/router/api.js +14 -10
  88. package/router/root.js +14 -0
  89. package/router/static.js +3 -3
  90. package/schemas/api.json +99 -0
  91. package/schemas/config.json +255 -0
  92. package/schemas/corn.json +39 -0
  93. package/schemas/item.json +43 -0
  94. package/schemas/menu.json +44 -0
  95. package/schemas/table.json +80 -0
  96. package/scripts/syncDb/apply.js +87 -0
  97. package/scripts/syncDb/ddl.js +43 -0
  98. package/scripts/syncDb/index.js +175 -30
  99. package/scripts/syncDb/model.js +60 -0
  100. package/scripts/syncDb/plan.js +160 -0
  101. package/scripts/syncDb/preflight.js +98 -0
  102. package/scripts/syncDb/report.js +177 -162
  103. package/scripts/syncDb/snapshot.js +118 -0
  104. package/sync/syncUtil.js +7 -13
  105. package/tables/admin.json +51 -53
  106. package/tables/api.json +29 -40
  107. package/tables/dailyReport.json +85 -73
  108. package/tables/dict.json +27 -39
  109. package/tables/dictType.json +20 -34
  110. package/tables/emailLog.json +49 -47
  111. package/tables/errorReport.json +86 -60
  112. package/tables/file.json +47 -50
  113. package/tables/loginLog.json +68 -55
  114. package/tables/menu.json +20 -34
  115. package/tables/operateLog.json +53 -47
  116. package/tables/project.json +18 -30
  117. package/tables/role.json +33 -37
  118. package/utils/cors.js +37 -41
  119. package/utils/deepMerge.js +3 -1
  120. package/utils/error.js +1 -56
  121. package/utils/id.js +44 -0
  122. package/utils/is.js +6 -243
  123. package/utils/response.js +12 -1
  124. package/utils/scanFiles.js +13 -4
  125. package/utils/scanSources.js +58 -20
  126. package/utils/util.js +11 -163
  127. package/apis/_apis.js +0 -20
  128. package/apis/auth/sendSmsCode.js +0 -24
  129. package/apis/role/delete.js +0 -41
  130. package/apis/role/insert.js +0 -45
  131. package/apis/role/save.js +0 -36
  132. package/apis/role/update.js +0 -66
  133. package/configs/regexpAlias.json +0 -55
  134. package/lib/connect.js +0 -119
  135. package/lib/logger.js +0 -430
  136. package/lib/redisHelper.js +0 -399
  137. package/lib/validator.js +0 -274
  138. package/lib/xmlParse.js +0 -149
  139. package/plugins/config.js +0 -13
  140. package/plugins/cron.js +0 -115
  141. package/plugins/logger.js +0 -17
  142. package/plugins/tool.js +0 -131
  143. package/plugins/xmlParse.js +0 -10
  144. package/schemas/config.js +0 -67
  145. package/scripts/syncDb/context.js +0 -120
  146. package/scripts/syncDb/diff.js +0 -279
  147. package/scripts/syncDb/transform.js +0 -113
  148. package/sql/befly.sql +0 -271
  149. package/utils/email.js +0 -3
  150. package/utils/fieldClear.js +0 -22
  151. package/utils/formatValidationIssues.js +0 -28
  152. package/utils/getClientIp.js +0 -47
  153. package/utils/loggerUtils.js +0 -196
  154. package/utils/regexpUtil.js +0 -52
  155. /package/{lib → libs}/smtpText.js +0 -0
@@ -0,0 +1,87 @@
1
+ import { SQL } from "bun";
2
+
3
+ function createExecutor(client) {
4
+ return {
5
+ execute: async function (statement, params = []) {
6
+ const startedAt = Date.now();
7
+ const data = params.length > 0 ? await client.unsafe(statement, params) : await client.unsafe(statement);
8
+ return { data: data, sql: { sql: statement, params: params, duration: Date.now() - startedAt } };
9
+ }
10
+ };
11
+ }
12
+
13
+ export function createSyncDbClient(mysqlConfig) {
14
+ const sql = new SQL({
15
+ adapter: "mysql",
16
+ hostname: mysqlConfig.hostname,
17
+ port: mysqlConfig.port,
18
+ database: mysqlConfig.database,
19
+ username: mysqlConfig.username,
20
+ password: mysqlConfig.password,
21
+ max: 1,
22
+ bigint: true,
23
+ connectionTimeout: mysqlConfig.connectionTimeout
24
+ });
25
+
26
+ return {
27
+ ...createExecutor(sql),
28
+ close: async function () {
29
+ await sql.close();
30
+ },
31
+ reserve: async function () {
32
+ const connection = await sql.reserve();
33
+ return {
34
+ ...createExecutor(connection),
35
+ release: function () {
36
+ connection.release();
37
+ }
38
+ };
39
+ }
40
+ };
41
+ }
42
+
43
+ function getLockName(database) {
44
+ const name = `befly:syncDb:${database}`;
45
+ if (name.length <= 64) return name;
46
+ const hash = new Bun.CryptoHasher("sha256").update(name).digest("hex").slice(0, 32);
47
+ return `befly:syncDb:${hash}`;
48
+ }
49
+
50
+ export async function applySchemaPlan(client, context) {
51
+ const connection = await client.reserve();
52
+ const lockName = getLockName(context.database);
53
+ let locked = false;
54
+ const results = [];
55
+ try {
56
+ const lockResult = await connection.execute("SELECT GET_LOCK(?, 0) AS acquired", [lockName]);
57
+ if (Number(lockResult.data?.[0]?.acquired) !== 1) throw new Error(`无法获取数据库同步锁:${lockName}`);
58
+ locked = true;
59
+ await connection.execute("SET SESSION lock_wait_timeout = 10");
60
+
61
+ const verified = await context.verify(connection);
62
+ if (verified.planHash !== context.plan.planHash) throw new Error("数据库结构或预检结果已变化,请重新预览后再执行");
63
+
64
+ for (const operation of verified.operations.filter((item) => item.preflight?.blocked !== true)) {
65
+ const startedAt = Date.now();
66
+ process.stdout.write(`[syncDb] 执行 ${operation.id}\n`);
67
+ try {
68
+ await connection.execute(operation.sql);
69
+ results.push({ id: operation.id, status: "success", duration: Date.now() - startedAt, error: "" });
70
+ } catch (error) {
71
+ results.push({ id: operation.id, status: "failed", duration: Date.now() - startedAt, error: String(error?.message || error) });
72
+ error.syncDbResults = results;
73
+ throw error;
74
+ }
75
+ }
76
+ return results;
77
+ } finally {
78
+ if (locked) {
79
+ try {
80
+ await connection.execute("SELECT RELEASE_LOCK(?) AS released", [lockName]);
81
+ } catch {
82
+ // 连接关闭时 MySQL 会自动释放命名锁
83
+ }
84
+ }
85
+ connection.release();
86
+ }
87
+ }
@@ -0,0 +1,43 @@
1
+ function quoteIdentifier(value) {
2
+ if (!/^[a-z][a-z0-9_]*$/.test(value)) throw new TypeError(`无效数据库标识符 ${value}`);
3
+ return `\`${value}\``;
4
+ }
5
+
6
+ function quoteString(value) {
7
+ return `'${String(value).replaceAll("\\", "\\\\").replaceAll("'", "''")}'`;
8
+ }
9
+
10
+ function formatDefault(column) {
11
+ if (!column.default.hasDefault) return "";
12
+ return ` DEFAULT ${typeof column.default.value === "number" ? String(column.default.value) : quoteString(column.default.value)}`;
13
+ }
14
+
15
+ export function columnSql(column) {
16
+ const type = column.fieldType === "integer" ? "BIGINT" : column.fieldType === "number" ? `DECIMAL(${column.precision}, ${column.scale})` : column.fieldType === "varchar" ? `VARCHAR(${column.maxValue})` : "MEDIUMTEXT";
17
+ return `${quoteIdentifier(column.dbName)} ${type} ${column.nullable ? "NULL" : "NOT NULL"}${formatDefault(column)}${column.autoIncrement ? " AUTO_INCREMENT" : ""} COMMENT ${quoteString(column.name)}`;
18
+ }
19
+
20
+ function indexSql(index) {
21
+ const fields = index.fields.map(quoteIdentifier).join(", ");
22
+ return `${index.type === "unique" ? "UNIQUE " : ""}KEY ${quoteIdentifier(index.name)} (${fields})`;
23
+ }
24
+
25
+ export function createTableSql(table) {
26
+ const columns = Object.values(table.columns).map(columnSql);
27
+ const primary = `PRIMARY KEY (${quoteIdentifier(table.primary)})`;
28
+ const indexes = Object.values(table.indexes).map(indexSql);
29
+ return `CREATE TABLE ${quoteIdentifier(table.dbName)} (\n ${[...columns, primary, ...indexes].join(",\n ")}\n) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT=${quoteString(table.name)}`;
30
+ }
31
+
32
+ export function operationSql(operation) {
33
+ const table = quoteIdentifier(operation.table);
34
+ if (operation.kind === "createTable") return createTableSql(operation.after);
35
+ if (operation.kind === "alterTableComment") return `ALTER TABLE ${table} COMMENT=${quoteString(operation.after)}`;
36
+ if (operation.kind === "alterTableCollation") return `ALTER TABLE ${table} CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci`;
37
+ if (operation.kind === "addColumn") return `ALTER TABLE ${table} ADD COLUMN ${columnSql(operation.after)}`;
38
+ if (operation.kind === "alterColumn") return `ALTER TABLE ${table} MODIFY COLUMN ${columnSql(operation.after)}`;
39
+ if (operation.kind === "addIndex") return `ALTER TABLE ${table} ADD ${indexSql(operation.after)}`;
40
+ if (operation.kind === "replaceIndex") return `ALTER TABLE ${table} DROP INDEX ${quoteIdentifier(operation.object)}, ADD ${indexSql(operation.after)}`;
41
+ if (operation.kind === "dropIndex") return `ALTER TABLE ${table} DROP INDEX ${quoteIdentifier(operation.object)}`;
42
+ throw new TypeError(`不支持的 syncDb operation ${operation.kind}`);
43
+ }
@@ -1,43 +1,188 @@
1
- import { join, resolve } from "node:path";
2
-
3
- import { Connect } from "#befly/lib/connect.js";
4
- import { Logger } from "#befly/lib/logger.js";
1
+ import { checkTable } from "#befly/checks/table.js";
2
+ import { Logger } from "#befly/libs/logger/index.js";
5
3
  import { createError } from "#befly/utils/error.js";
4
+ import { isPlainObject } from "#befly/utils/is.js";
5
+ import { scanTables } from "#befly/utils/scanSources.js";
6
+
7
+ import { applySchemaPlan, createSyncDbClient } from "./apply.js";
8
+ import { buildTargetDatabaseModel } from "./model.js";
9
+ import { buildSchemaPlan } from "./plan.js";
10
+ import { runPreflight } from "./preflight.js";
11
+ import { printPreflightFailures, printSyncPlan, writeSyncArchive } from "./report.js";
12
+ import { readDatabaseSnapshot } from "./snapshot.js";
13
+
14
+ function pad(value) {
15
+ return String(value).padStart(2, "0");
16
+ }
17
+
18
+ function nowText() {
19
+ const date = new Date();
20
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
21
+ }
6
22
 
7
- import { prepareSyncDbBaseContext } from "./context.js";
8
- import { applySyncDbDiff } from "./diff.js";
9
- import { printSyncDbDiffSummary, printSyncDbProcessLog, writeSyncDbReport } from "./report.js";
23
+ async function buildPlan(mysql, targetTables, singleTable) {
24
+ const snapshot = await readDatabaseSnapshot(mysql);
25
+ const plan = buildSchemaPlan(targetTables, snapshot.tables, { singleTable: singleTable });
26
+ await runPreflight(mysql, plan);
27
+ return { mysqlVersion: snapshot.mysqlVersion, plan: plan };
28
+ }
29
+
30
+ export async function syncDb(mysqlConfig, options = {}) {
31
+ const apply = options.apply === true;
32
+ const list = options.list === true;
33
+ const tableName = options.tableName || "";
34
+ const startedAt = nowText();
35
+ let client;
36
+ let targetTables;
37
+ let archivePath = "";
38
+ let context = {
39
+ apply: apply,
40
+ tableName: tableName,
41
+ hostname: mysqlConfig?.hostname,
42
+ port: mysqlConfig?.port,
43
+ username: mysqlConfig?.username,
44
+ database: mysqlConfig?.database || "",
45
+ mysqlVersion: "",
46
+ plan: { operations: [], notices: [], blocked: [], counts: { createTable: 0, safe: 0, warning: 0, destructive: 0, notice: 0, blocked: 0 }, planHash: "" },
47
+ startedAt: startedAt,
48
+ finishedAt: "",
49
+ status: "running",
50
+ results: [],
51
+ remainingPlan: null
52
+ };
10
53
 
11
- export async function syncDb(mysqlConfig) {
12
- const reportPath = resolve(join(process.cwd(), "db.sync.md"));
13
54
  try {
14
- printSyncDbProcessLog("开始执行应用");
15
- const { diff, groupedDbColumns, tablesDir, existingTableMap } = await prepareSyncDbBaseContext(mysqlConfig);
16
- const writtenReportPath = await writeSyncDbReport(reportPath, diff, groupedDbColumns, existingTableMap);
17
- printSyncDbDiffSummary(diff);
18
- const applyResult = await applySyncDbDiff(diff, tablesDir, existingTableMap);
19
- const hasDiff = diff.missingTables.length > 0 || applyResult.appendedFieldCount > 0 || diff.extraTables.length > 0 || applyResult.removedFieldCount > 0;
55
+ if (!isPlainObject(mysqlConfig)) throw new TypeError("syncDb 缺少 mysql 配置");
56
+ process.stdout.write("[syncDb] 数据库连接\n");
57
+ process.stdout.write(`[syncDb] 地址:${mysqlConfig.hostname}\n`);
58
+ process.stdout.write(`[syncDb] 端口:${mysqlConfig.port}\n`);
59
+ process.stdout.write(`[syncDb] 用户名:${mysqlConfig.username}\n`);
60
+ process.stdout.write(`[syncDb] 数据库名:${mysqlConfig.database}\n\n`);
61
+ if (apply) archivePath = await writeSyncArchive(context);
62
+ const tables = await scanTables({ beflyMode: mysqlConfig.beflyMode });
63
+ const tableErrors = await checkTable(tables);
64
+ if (tableErrors.length > 0) throw createError("Tables 定义校验失败", { code: "policy", subsystem: "scripts", operation: "syncDb", errors: tableErrors });
65
+
66
+ targetTables = buildTargetDatabaseModel(tables, tableName);
67
+ client = createSyncDbClient(mysqlConfig);
68
+ const built = await buildPlan(client, targetTables, Boolean(tableName));
69
+ context = {
70
+ ...context,
71
+ hostname: mysqlConfig.hostname,
72
+ port: mysqlConfig.port,
73
+ username: mysqlConfig.username,
74
+ database: mysqlConfig.database,
75
+ mysqlVersion: built.mysqlVersion,
76
+ plan: built.plan,
77
+ startedAt: startedAt,
78
+ finishedAt: nowText(),
79
+ status: apply ? "ready" : built.plan.blocked.length > 0 ? "blocked" : built.plan.operations.length > 0 ? "preview" : "no-change",
80
+ results: [],
81
+ remainingPlan: built.plan
82
+ };
83
+
84
+ if (list) {
85
+ const rows = printPreflightFailures(context.plan);
86
+ return {
87
+ status: "list",
88
+ database: context.database,
89
+ mysqlVersion: context.mysqlVersion,
90
+ operationCount: context.plan.operations.length,
91
+ blockedCount: context.plan.blocked.length,
92
+ preflightFailureCount: rows.length,
93
+ noticeCount: context.plan.notices.length,
94
+ planHash: context.plan.planHash,
95
+ archivePath: ""
96
+ };
97
+ }
98
+ printSyncPlan(context);
99
+ if (!apply) {
100
+ return {
101
+ status: context.status,
102
+ database: context.database,
103
+ mysqlVersion: context.mysqlVersion,
104
+ operationCount: context.plan.operations.length,
105
+ blockedCount: context.plan.blocked.length,
106
+ noticeCount: context.plan.notices.length,
107
+ planHash: context.plan.planHash,
108
+ archivePath: ""
109
+ };
110
+ }
111
+
112
+ archivePath = await writeSyncArchive(context, archivePath);
113
+ process.stdout.write(`[syncDb] 同步存档:${archivePath}\n`);
114
+ const executableOperations = context.plan.operations.filter((operation) => operation.preflight?.blocked !== true);
115
+ if (executableOperations.length === 0) {
116
+ const finalBuilt = await buildPlan(client, targetTables, Boolean(tableName));
117
+ context.remainingPlan = finalBuilt.plan;
118
+ context.mysqlVersion = finalBuilt.mysqlVersion;
119
+ context.status = finalBuilt.plan.blocked.length > 0 ? "blocked" : "no-change";
120
+ context.finishedAt = nowText();
121
+ await writeSyncArchive(context, archivePath);
122
+ process.stdout.write("\n[syncDb] 剩余未同步内容\n");
123
+ printSyncPlan({ ...context, apply: false, plan: finalBuilt.plan });
124
+ return {
125
+ status: context.status,
126
+ database: context.database,
127
+ mysqlVersion: context.mysqlVersion,
128
+ operationCount: context.plan.operations.length,
129
+ appliedCount: 0,
130
+ blockedCount: finalBuilt.plan.blocked.length,
131
+ noticeCount: finalBuilt.plan.notices.length,
132
+ remainingOperationCount: finalBuilt.plan.operations.length,
133
+ planHash: context.plan.planHash,
134
+ archivePath: archivePath
135
+ };
136
+ }
20
137
 
21
- printSyncDbProcessLog(
22
- `应用完成,缺少表数量=${diff.missingTables.length},缺少字段数量=${applyResult.appendedFieldCount},多余表数量=${diff.extraTables.length},多余字段数量=${diff.extraFieldCount},新建表定义文件数量=${applyResult.createdTableFileCount},补充字段数量=${applyResult.appendedFieldCount},删除字段数量=${applyResult.removedFieldCount}`
23
- );
138
+ context.results = await applySchemaPlan(client, {
139
+ database: mysqlConfig.database,
140
+ plan: context.plan,
141
+ verify: async (connection) => (await buildPlan(connection, targetTables, Boolean(tableName))).plan
142
+ });
143
+ const finalBuilt = await buildPlan(client, targetTables, Boolean(tableName));
144
+ context.remainingPlan = finalBuilt.plan;
145
+ context.mysqlVersion = finalBuilt.mysqlVersion;
146
+ context.status = finalBuilt.plan.operations.length === 0 && finalBuilt.plan.blocked.length === 0 ? "applied" : "partial";
147
+ context.finishedAt = nowText();
148
+ await writeSyncArchive(context, archivePath);
149
+ process.stdout.write("\n[syncDb] 剩余未同步内容\n");
150
+ printSyncPlan({ ...context, apply: false, plan: finalBuilt.plan });
24
151
 
25
152
  return {
26
- mode: "apply",
27
- createdTableFileCount: applyResult.createdTableFileCount,
28
- appendedFieldCount: applyResult.appendedFieldCount,
29
- removedFieldCount: applyResult.removedFieldCount,
30
- missingTableCount: diff.missingTables.length,
31
- missingFieldCount: applyResult.appendedFieldCount,
32
- extraTableCount: diff.extraTables.length,
33
- extraFieldCount: diff.extraFieldCount,
34
- hasDiff: hasDiff,
35
- reportPath: writtenReportPath
153
+ status: context.status,
154
+ database: context.database,
155
+ mysqlVersion: context.mysqlVersion,
156
+ operationCount: context.plan.operations.length,
157
+ appliedCount: context.results.length,
158
+ blockedCount: finalBuilt.plan.blocked.length,
159
+ noticeCount: finalBuilt.plan.notices.length,
160
+ remainingOperationCount: finalBuilt.plan.operations.length,
161
+ planHash: context.plan.planHash,
162
+ archivePath: archivePath
36
163
  };
37
164
  } catch (error) {
38
- throw createError("执行表定义同步失败", { cause: error, code: "runtime", subsystem: "scripts", operation: "syncDb" });
165
+ if (context && Array.isArray(error?.syncDbResults)) context.results = error.syncDbResults;
166
+ if (apply) {
167
+ context.status = context.results?.some((result) => result.status === "success") ? "partial" : context.plan?.blocked.length > 0 ? "blocked" : "failed";
168
+ context.finishedAt = nowText();
169
+ context.error = String(error?.message || error);
170
+ if (client && targetTables && context.results?.length > 0) {
171
+ try {
172
+ context.remainingPlan = (await buildPlan(client, targetTables, Boolean(tableName))).plan;
173
+ } catch {
174
+ // 保留原始执行错误,存档沿用执行前计划
175
+ }
176
+ }
177
+ try {
178
+ archivePath = await writeSyncArchive(context, archivePath);
179
+ } catch (archiveError) {
180
+ throw createError("同步失败且无法写入同步存档", { cause: archiveError, code: "runtime", subsystem: "scripts", operation: "syncDb" });
181
+ }
182
+ }
183
+ throw createError("执行数据库结构同步失败", { cause: error, code: "runtime", subsystem: "scripts", operation: "syncDb" });
39
184
  } finally {
40
- await Connect.disconnect();
185
+ await client?.close();
41
186
  await Logger.flush();
42
187
  }
43
188
  }
@@ -0,0 +1,60 @@
1
+ import { snakeCase } from "#befly/utils/util.js";
2
+
3
+ function normalizeDefault(value) {
4
+ return value === undefined ? { hasDefault: false, value: undefined } : { hasDefault: true, value: value };
5
+ }
6
+
7
+ function buildColumn(fieldName, field) {
8
+ return {
9
+ codeName: fieldName,
10
+ dbName: snakeCase(fieldName),
11
+ name: field.name,
12
+ detail: field.detail,
13
+ fieldType: field.fieldType,
14
+ nullable: field.fieldNullable,
15
+ default: normalizeDefault(field.fieldDefault),
16
+ maxValue: field.maxValue,
17
+ precision: field.precision,
18
+ scale: field.scale,
19
+ primary: field.primary === true,
20
+ autoIncrement: field.autoIncrement === true,
21
+ unsigned: false
22
+ };
23
+ }
24
+
25
+ export function buildTargetDatabaseModel(tables, tableName) {
26
+ let selectedTables = Object.values(tables);
27
+ if (tableName) {
28
+ selectedTables = selectedTables.filter((table) => table.codeName === tableName || table.fileName === tableName);
29
+ if (selectedTables.length === 0) throw new TypeError(`找不到 Table 定义:${tableName}`);
30
+ }
31
+
32
+ return Object.fromEntries(
33
+ selectedTables
34
+ .toSorted((left, right) => snakeCase(left.codeName).localeCompare(snakeCase(right.codeName)))
35
+ .map((table) => [
36
+ snakeCase(table.codeName),
37
+ {
38
+ codeName: table.codeName,
39
+ fileName: table.fileName,
40
+ dbName: snakeCase(table.codeName),
41
+ name: table.name,
42
+ primary: snakeCase(table.primary),
43
+ engine: "INNODB",
44
+ charset: "utf8mb4",
45
+ collation: "utf8mb4_0900_ai_ci",
46
+ columns: Object.fromEntries(Object.entries(table.fields).map(([fieldName, field]) => [snakeCase(fieldName), buildColumn(fieldName, field)])),
47
+ indexes: Object.fromEntries(
48
+ Object.entries(table.indexes).map(([indexName, index]) => [
49
+ indexName,
50
+ {
51
+ name: indexName,
52
+ type: index.type,
53
+ fields: index.fields.map((fieldName) => snakeCase(fieldName))
54
+ }
55
+ ])
56
+ )
57
+ }
58
+ ])
59
+ );
60
+ }
@@ -0,0 +1,160 @@
1
+ import { operationSql } from "./ddl.js";
2
+
3
+ function sameDefault(left, right) {
4
+ if (left.hasDefault !== right.hasDefault) return false;
5
+ if (!left.hasDefault) return true;
6
+ if (typeof left.value === "number" || typeof right.value === "number") return Number(left.value) === Number(right.value);
7
+ return String(left.value) === String(right.value);
8
+ }
9
+
10
+ function sameIndex(left, right) {
11
+ return left.type === right.type && left.fields.length === right.fields.length && left.fields.every((field, index) => field === right.fields[index]);
12
+ }
13
+
14
+ function targetDbType(fieldType) {
15
+ return { integer: "bigint", number: "decimal", varchar: "varchar", text: "mediumtext" }[fieldType];
16
+ }
17
+
18
+ function compatibleFieldType(target, actual) {
19
+ const sources = {
20
+ integer: new Set(["integer", "tinyint", "smallint", "mediumint", "int"]),
21
+ number: new Set(["number"]),
22
+ varchar: new Set(["varchar", "char"]),
23
+ text: new Set(["text", "tinytext", "longtext", "json", "varchar", "char"])
24
+ };
25
+ return sources[target]?.has(actual) === true;
26
+ }
27
+
28
+ function columnChanges(target, actual) {
29
+ const changes = [];
30
+ for (const property of ["fieldType", "maxValue", "precision", "scale", "nullable", "name", "unsigned"]) {
31
+ if (target[property] !== actual[property]) changes.push({ property: property, before: actual[property], after: target[property] });
32
+ }
33
+ if (!sameDefault(target.default, actual.default)) changes.push({ property: "default", before: actual.default, after: target.default });
34
+ return changes;
35
+ }
36
+
37
+ function columnRisk(target, actual, changes) {
38
+ if (target.fieldType === "varchar" && target.maxValue < actual.maxValue) return "warning";
39
+ if (target.fieldType === "number" && (target.precision < actual.precision || target.scale < actual.scale)) return "warning";
40
+ if (actual.nullable && !target.nullable) return "warning";
41
+ return changes.every((change) => change.property === "name") ? "safe" : "warning";
42
+ }
43
+
44
+ function addOperation(plan, operation) {
45
+ operation.id = `${operation.kind}:${operation.table}${operation.object ? `.${operation.object}` : ""}`;
46
+ operation.sql = operationSql(operation);
47
+ plan.operations.push(operation);
48
+ }
49
+
50
+ function addBlocked(plan, blocked) {
51
+ blocked.id = `${blocked.kind}:${blocked.table}${blocked.object ? `.${blocked.object}` : ""}`;
52
+ plan.blocked.push(blocked);
53
+ }
54
+
55
+ function addNotice(plan, notice) {
56
+ notice.id = `${notice.kind}:${notice.table}${notice.object ? `.${notice.object}` : ""}`;
57
+ plan.notices.push(notice);
58
+ }
59
+
60
+ export function updatePlanHash(plan) {
61
+ const payload = {
62
+ operations: plan.operations.map(({ id, kind, risk, table, object, before, after, changes, preflight, sql }) => ({ id: id, kind: kind, risk: risk, table: table, object: object, before: before, after: after, changes: changes, preflight: preflight, sql: sql })),
63
+ blocked: plan.blocked,
64
+ notices: plan.notices
65
+ };
66
+ plan.planHash = new Bun.CryptoHasher("sha256").update(JSON.stringify(payload)).digest("hex");
67
+ return plan;
68
+ }
69
+
70
+ export function buildSchemaPlan(targetTables, actualTables, { singleTable = false } = {}) {
71
+ const plan = { operations: [], notices: [], blocked: [], counts: {} };
72
+
73
+ for (const target of Object.values(targetTables)) {
74
+ const actual = actualTables[target.dbName];
75
+ if (!actual) {
76
+ addOperation(plan, { kind: "createTable", risk: "safe", table: target.dbName, before: null, after: target, changes: [] });
77
+ continue;
78
+ }
79
+
80
+ if (actual.engine !== target.engine) addBlocked(plan, { kind: "tableEngine", table: target.dbName, before: actual.engine, after: target.engine, reason: "不自动转换表引擎" });
81
+ if (actual.charset !== target.charset) addBlocked(plan, { kind: "tableCharset", table: target.dbName, before: actual.charset, after: target.charset, reason: "不自动执行跨字符集转换" });
82
+ else if (actual.collation !== target.collation) addOperation(plan, { kind: "alterTableCollation", risk: "warning", table: target.dbName, before: actual.collation, after: target.collation, changes: [{ property: "collation", before: actual.collation, after: target.collation }] });
83
+ if (actual.name !== target.name) addOperation(plan, { kind: "alterTableComment", risk: "safe", table: target.dbName, before: actual.name, after: target.name, changes: [{ property: "name", before: actual.name, after: target.name }] });
84
+
85
+ if (actual.primary !== target.primary) addBlocked(plan, { kind: "tablePrimary", table: target.dbName, before: actual.primary, after: target.primary, reason: "不自动修改主键" });
86
+ const targetPrimary = target.columns[target.primary];
87
+ const actualPrimary = actual.columns[target.primary];
88
+ if (actual.primary === target.primary && actualPrimary && actualPrimary.autoIncrement !== targetPrimary.autoIncrement)
89
+ addBlocked(plan, { kind: "tableAutoIncrement", table: target.dbName, object: target.primary, before: actualPrimary.autoIncrement, after: targetPrimary.autoIncrement, reason: "不自动修改主键自增属性" });
90
+
91
+ for (const targetColumn of Object.values(target.columns)) {
92
+ const actualColumn = actual.columns[targetColumn.dbName];
93
+ if (!actualColumn) {
94
+ addOperation(plan, { kind: "addColumn", risk: targetColumn.nullable || targetColumn.default.hasDefault ? "safe" : "warning", table: target.dbName, object: targetColumn.dbName, before: null, after: targetColumn, changes: [] });
95
+ continue;
96
+ }
97
+ const sourceType = actualColumn.dbType || targetDbType(actualColumn.fieldType) || actualColumn.fieldType;
98
+ const sourceDisplayType = actualColumn.dbType || actualColumn.fieldType;
99
+ const sourceFieldType = sourceType === "bigint" ? "integer" : sourceType === "decimal" || sourceType === "numeric" ? "number" : sourceType === "mediumtext" ? "text" : sourceType;
100
+ if (!compatibleFieldType(targetColumn.fieldType, sourceFieldType)) {
101
+ addBlocked(plan, { kind: "columnType", table: target.dbName, object: targetColumn.dbName, before: sourceDisplayType, after: targetColumn.fieldType, target: targetColumn, reason: "不允许自动执行不兼容字段类型转换" });
102
+ continue;
103
+ }
104
+ const physicalTypeChanged = sourceType !== targetDbType(targetColumn.fieldType);
105
+ if (targetColumn.fieldType === "text" && physicalTypeChanged && Object.values(actual.indexes).some((index) => index.fields.includes(targetColumn.dbName))) {
106
+ addBlocked(plan, { kind: "columnIndexedText", table: target.dbName, object: targetColumn.dbName, before: sourceDisplayType, after: targetColumn.fieldType, target: targetColumn, reason: "索引字段不能直接转换为 MEDIUMTEXT" });
107
+ continue;
108
+ }
109
+ if (targetColumn.primary !== actualColumn.primary || targetColumn.autoIncrement !== actualColumn.autoIncrement) continue;
110
+ const changes = columnChanges(targetColumn, actualColumn).filter((change) => change.property !== "fieldType");
111
+ if (physicalTypeChanged) changes.unshift({ property: "fieldType", before: sourceDisplayType, after: targetColumn.fieldType });
112
+ if (changes.length > 0) addOperation(plan, { kind: "alterColumn", risk: columnRisk(targetColumn, actualColumn, changes), table: target.dbName, object: targetColumn.dbName, before: actualColumn, after: targetColumn, changes: changes });
113
+ }
114
+
115
+ for (const actualColumn of Object.values(actual.columns)) {
116
+ if (!target.columns[actualColumn.dbName]) addNotice(plan, { kind: "unmanagedColumn", table: target.dbName, object: actualColumn.dbName, message: "字段仅存在于 MySQL,按只增不减规则保留" });
117
+ }
118
+
119
+ for (const targetIndex of Object.values(target.indexes)) {
120
+ const actualIndex = actual.indexes[targetIndex.name];
121
+ if (!actualIndex) addOperation(plan, { kind: "addIndex", risk: targetIndex.type === "unique" ? "warning" : "safe", table: target.dbName, object: targetIndex.name, before: null, after: targetIndex, changes: [] });
122
+ else if (!actualIndex.supported || !sameIndex(targetIndex, actualIndex))
123
+ addOperation(plan, {
124
+ kind: "replaceIndex",
125
+ risk: "warning",
126
+ table: target.dbName,
127
+ object: targetIndex.name,
128
+ before: actualIndex,
129
+ after: targetIndex,
130
+ changes: [
131
+ { property: "type", before: actualIndex.type, after: targetIndex.type },
132
+ { property: "fields", before: actualIndex.fields, after: targetIndex.fields }
133
+ ]
134
+ });
135
+ }
136
+ for (const actualIndex of Object.values(actual.indexes)) {
137
+ if (!target.indexes[actualIndex.name]) addOperation(plan, { kind: "dropIndex", risk: "destructive", table: target.dbName, object: actualIndex.name, before: actualIndex, after: null, changes: [] });
138
+ }
139
+ }
140
+
141
+ if (!singleTable) {
142
+ for (const actual of Object.values(actualTables)) {
143
+ if (!targetTables[actual.dbName]) addNotice(plan, { kind: "unmanagedTable", table: actual.dbName, message: "表仅存在于 MySQL,保留且不受同步管理" });
144
+ }
145
+ }
146
+
147
+ plan.operations.sort((left, right) => {
148
+ const order = { createTable: 10, alterTableComment: 20, alterTableCollation: 30, addColumn: 40, alterColumn: 50, addIndex: 60, replaceIndex: 70, dropIndex: 80 };
149
+ return order[left.kind] - order[right.kind] || left.table.localeCompare(right.table) || String(left.object || "").localeCompare(String(right.object || ""));
150
+ });
151
+ plan.counts = {
152
+ createTable: plan.operations.filter((item) => item.kind === "createTable").length,
153
+ safe: plan.operations.filter((item) => item.risk === "safe").length,
154
+ warning: plan.operations.filter((item) => item.risk === "warning").length,
155
+ destructive: plan.operations.filter((item) => item.risk === "destructive").length,
156
+ notice: plan.notices.length,
157
+ blocked: plan.blocked.length
158
+ };
159
+ return updatePlanHash(plan);
160
+ }