befly 2.3.1 → 2.3.3

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/scripts/syncDb.js CHANGED
@@ -1,295 +1,427 @@
1
1
  /**
2
- * 数据库表结构同步脚本 - 仅支持 MySQL 8.0+
2
+ * 数据库表结构同步脚本 - 支持 sqlite / mysql / postgresql
3
+ * 注意:MySQL 提供更完整的在线 ALTER 能力;SQLite/PG 的修改能力有差异,部分操作将跳过或分解。
3
4
  */
4
5
 
5
6
  import path from 'node:path';
6
7
  import { Env } from '../config/env.js';
7
8
  import { Logger } from '../utils/logger.js';
8
- import { parseFieldRule, createSqlClient } from '../utils/index.js';
9
+ import { createSqlClient, toSnakeTableName, isType, parseRule } from '../utils/index.js';
9
10
  import { __dirtables, getProjectDir } from '../system.js';
10
11
  import { checkTable } from '../checks/table.js';
11
12
 
12
- const typeMapping = {
13
- number: 'BIGINT',
14
- string: 'VARCHAR',
15
- text: 'MEDIUMTEXT',
16
- array: 'VARCHAR'
17
- };
13
+ // 顶部管理数据库客户端(按需求使用 Bun SQL 模板,不使用 exec 辅助)
14
+ let sql = null;
18
15
 
19
- // 环境开关读取(支持未在 Env 显式声明的变量,默认值兜底)
20
- const getFlag = (val, def = 0) => {
21
- if (val === undefined || val === null || val === '') return !!def;
22
- const n = Number(val);
23
- if (!Number.isNaN(n)) return n !== 0;
24
- const s = String(val).toLowerCase();
25
- return s === 'true' || s === 'on' || s === 'yes';
26
- };
27
-
28
- // 命令行参数
16
+ // 方言与类型映射
17
+ const DB = (Env.DB_TYPE || 'mysql').toLowerCase();
18
+ const IS_MYSQL = DB === 'mysql';
19
+ const IS_PG = DB === 'postgresql' || DB === 'postgres';
20
+ const IS_SQLITE = DB === 'sqlite'; // 命令行参数
29
21
  const ARGV = Array.isArray(process.argv) ? process.argv : [];
30
- const CLI = {
31
- DRY_RUN: ARGV.includes('--dry-run')
32
- };
22
+ const IS_PLAN = ARGV.includes('--plan');
33
23
 
34
- const FLAGS = {
35
- // DRY-RUN 改为命令行参数控制,忽略环境变量
36
- DRY_RUN: CLI.DRY_RUN, // 仅打印计划,不执行
37
- MERGE_ALTER: getFlag(Env.SYNC_MERGE_ALTER, 1), // 合并每表多项 DDL
38
- ONLINE_INDEX: getFlag(Env.SYNC_ONLINE_INDEX, 1), // 索引操作使用在线算法
39
- DISALLOW_SHRINK: getFlag(Env.SYNC_DISALLOW_SHRINK, 1), // 禁止长度收缩
40
- ALLOW_TYPE_CHANGE: getFlag(Env.SYNC_ALLOW_TYPE_CHANGE, 0) // 允许类型变更
24
+ // 字段类型映射(按方言)
25
+ const typeMapping = {
26
+ number: IS_SQLITE ? 'INTEGER' : 'BIGINT',
27
+ string: IS_SQLITE ? 'TEXT' : IS_PG ? 'character varying' : 'VARCHAR',
28
+ text: IS_MYSQL ? 'MEDIUMTEXT' : 'TEXT',
29
+ array: IS_SQLITE ? 'TEXT' : IS_PG ? 'character varying' : 'VARCHAR'
41
30
  };
42
31
 
43
- // 计算期望的默认值(与 information_schema 返回的值对齐)
44
- // 规则:当默认值为 'null' 时按类型提供默认值:number→0,string→"",array→"[]";text 永不设置默认值
45
- const getExpectedDefault = (fieldType, fieldDefaultValue) => {
46
- if (fieldType === 'text') return null; // TEXT 不设置默认
47
- if (fieldDefaultValue !== undefined && fieldDefaultValue !== null && fieldDefaultValue !== 'null') {
48
- return fieldDefaultValue; // 保留显式默认值(数字或字符串,包含空字符串)
49
- }
50
- // 规则为 'null' 时的内置默认
51
- switch (fieldType) {
52
- case 'number':
53
- return 0;
54
- case 'string':
55
- return '';
56
- case 'array':
57
- return '[]';
58
- default:
59
- return null;
60
- }
32
+ // 全局统计
33
+ const globalCount = {
34
+ // 表级
35
+ processedTables: 0,
36
+ createdTables: 0,
37
+ modifiedTables: 0,
38
+ // 字段与索引级
39
+ addFields: 0,
40
+ typeChanges: 0,
41
+ maxChanges: 0, // 映射为长度变化
42
+ minChanges: 0, // 最小值不参与 DDL,比对保留为0
43
+ defaultChanges: 0,
44
+ nameChanges: 0, // 字段显示名(注释)变更
45
+ indexCreate: 0,
46
+ indexDrop: 0
61
47
  };
62
48
 
63
- const normalizeDefault = (val) => (val === null || val === undefined ? null : String(val));
64
-
65
- // 获取字段的SQL定义
66
- const getColumnDefinition = (fieldName, rule) => {
67
- const [fieldDisplayName, fieldType, fieldMin, fieldMaxLength, fieldDefaultValue, fieldHasIndex] = parseFieldRule(rule);
68
-
69
- let sqlType = typeMapping[fieldType];
70
- if (!sqlType) throw new Error(`不支持的数据类型: ${fieldType}`);
49
+ // PG 兼容类型变更识别:无需数据重写的宽化型变更
50
+ const isPgCompatibleTypeChange = (currentType, newType) => {
51
+ const c = String(currentType || '').toLowerCase();
52
+ const n = String(newType || '').toLowerCase();
53
+ // varchar -> text 视为宽化
54
+ if (c === 'character varying' && n === 'text') return true;
55
+ // text -> character varying 非宽化(可能截断),不兼容
56
+ return false;
57
+ };
71
58
 
72
- // 根据字段类型设置SQL类型和长度
73
- if (fieldType === 'string' || fieldType === 'array') {
74
- const maxLength = parseInt(fieldMaxLength);
75
- sqlType = `VARCHAR(${maxLength})`;
59
+ // 数据库版本检查(按方言)
60
+ const ensureDbVersion = async () => {
61
+ if (!sql) throw new Error('SQL 客户端未初始化');
62
+ if (IS_MYSQL) {
63
+ const r = await sql`SELECT VERSION() AS version`;
64
+ const version = r[0].version;
65
+ const majorVersion = parseInt(String(version).split('.')[0], 10);
66
+ if (!Number.isFinite(majorVersion) || majorVersion < 8) {
67
+ throw new Error(`此脚本仅支持 MySQL 8.0+,当前版本: ${version}`);
68
+ }
69
+ Logger.info(`MySQL 版本: ${version}`);
70
+ return;
76
71
  }
77
-
78
- // 统一强制 NOT NULL
79
- let columnDef = `\`${fieldName}\` ${sqlType} NOT NULL`;
80
-
81
- // 设置默认值:类型非 text 时总是设置(显式默认或内置默认)
82
- const expectedDefault = getExpectedDefault(fieldType, fieldDefaultValue);
83
- if (fieldType !== 'text' && expectedDefault !== null) {
84
- if (fieldType === 'number') {
85
- columnDef += ` DEFAULT ${expectedDefault}`;
86
- } else {
87
- columnDef += ` DEFAULT \"${String(expectedDefault).replace(/\"/g, '\\"')}\"`;
72
+ if (IS_PG) {
73
+ const r = await sql`SELECT version() AS version`;
74
+ const versionText = r[0].version;
75
+ Logger.info(`PostgreSQL 版本: ${versionText}`);
76
+ const m = /PostgreSQL\s+(\d+)/i.exec(versionText);
77
+ const major = m ? parseInt(m[1], 10) : NaN;
78
+ if (!Number.isFinite(major) || major < 17) {
79
+ throw new Error(`此脚本要求 PostgreSQL >= 17,当前: ${versionText}`);
88
80
  }
81
+ return;
89
82
  }
90
- // text 类型不设置默认值
91
-
92
- // 添加字段注释(使用第1个属性作为字段显示名称)
93
- if (fieldDisplayName && fieldDisplayName !== 'null') {
94
- columnDef += ` COMMENT "${fieldDisplayName.replace(/"/g, '\\"')}"`;
83
+ if (IS_SQLITE) {
84
+ const r = await sql`SELECT sqlite_version() AS version`;
85
+ const version = r[0].version;
86
+ Logger.info(`SQLite 版本: ${version}`);
87
+ // 强制最低版本:SQLite 3.50.0
88
+ const [maj, min, patch] = String(version)
89
+ .split('.')
90
+ .map((v) => parseInt(v, 10) || 0);
91
+ const vnum = maj * 10000 + min * 100 + patch; // 3.50.0 -> 35000
92
+ if (!Number.isFinite(vnum) || vnum < 35000) {
93
+ throw new Error(`此脚本要求 SQLite >= 3.50.0,当前: ${version}`);
94
+ }
95
+ return;
95
96
  }
96
-
97
- return columnDef;
98
97
  };
99
98
 
100
- // 通用执行器:直接使用 Bun SQL 参数化(MySQL 使用 '?' 占位符)
101
- const exec = async (client, query, params = []) => {
102
- if (params && params.length > 0) {
103
- return await client.unsafe(query, params);
99
+ // 判断表是否存在(返回布尔值)
100
+ const tableExists = async (tableName) => {
101
+ if (!sql) throw new Error('SQL 客户端未初始化');
102
+ if (IS_MYSQL) {
103
+ const res = await sql`SELECT COUNT(*) AS count FROM information_schema.TABLES WHERE TABLE_SCHEMA = ${Env.DB_NAME} AND TABLE_NAME = ${tableName}`;
104
+ return (res[0]?.count || 0) > 0;
105
+ }
106
+ if (IS_PG) {
107
+ const res = await sql`SELECT COUNT(*)::int AS count FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ${tableName}`;
108
+ return (res[0]?.count || 0) > 0;
104
109
  }
105
- return await client.unsafe(query);
110
+ if (IS_SQLITE) {
111
+ const res = await sql`SELECT name FROM sqlite_master WHERE type='table' AND name = ${tableName}`;
112
+ return res.length > 0;
113
+ }
114
+ return false;
106
115
  };
107
116
 
108
- // 获取表的现有列信息
109
- const getTableColumns = async (client, tableName) => {
110
- const result = await exec(
111
- client,
112
- `SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_TYPE
113
- FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`,
114
- [Env.MYSQL_DB || 'test', tableName]
115
- );
116
-
117
+ // 获取表的现有列信息(按方言)
118
+ const getTableColumns = async (tableName) => {
117
119
  const columns = {};
118
- result.forEach((row) => {
119
- columns[row.COLUMN_NAME] = {
120
- type: row.DATA_TYPE,
121
- columnType: row.COLUMN_TYPE,
122
- length: row.CHARACTER_MAXIMUM_LENGTH,
123
- nullable: row.IS_NULLABLE === 'YES',
124
- defaultValue: row.COLUMN_DEFAULT,
125
- comment: row.COLUMN_COMMENT
126
- };
127
- });
120
+ if (IS_MYSQL) {
121
+ const result = await sql`
122
+ SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_TYPE
123
+ FROM information_schema.COLUMNS
124
+ WHERE TABLE_SCHEMA = ${Env.DB_NAME} AND TABLE_NAME = ${tableName}
125
+ ORDER BY ORDINAL_POSITION
126
+ `;
127
+ for (const row of result) {
128
+ columns[row.COLUMN_NAME] = {
129
+ type: row.DATA_TYPE,
130
+ columnType: row.COLUMN_TYPE,
131
+ length: row.CHARACTER_MAXIMUM_LENGTH,
132
+ nullable: row.IS_NULLABLE === 'YES',
133
+ defaultValue: row.COLUMN_DEFAULT,
134
+ comment: row.COLUMN_COMMENT
135
+ };
136
+ }
137
+ } else if (IS_PG) {
138
+ const result = await sql`SELECT column_name, data_type, character_maximum_length, is_nullable, column_default
139
+ FROM information_schema.columns
140
+ WHERE table_schema = 'public' AND table_name = ${tableName}
141
+ ORDER BY ordinal_position`;
142
+ // 获取列注释
143
+ const comments = await sql`SELECT a.attname AS column_name, col_description(c.oid, a.attnum) AS column_comment
144
+ FROM pg_class c
145
+ JOIN pg_attribute a ON a.attrelid = c.oid
146
+ JOIN pg_namespace n ON n.oid = c.relnamespace
147
+ WHERE c.relkind = 'r' AND n.nspname = 'public' AND c.relname = ${tableName} AND a.attnum > 0`;
148
+ const commentMap = {};
149
+ for (const r of comments) commentMap[r.column_name] = r.column_comment;
150
+ for (const row of result) {
151
+ columns[row.column_name] = {
152
+ type: row.data_type,
153
+ columnType: row.data_type,
154
+ length: row.character_maximum_length,
155
+ nullable: String(row.is_nullable).toUpperCase() === 'YES',
156
+ defaultValue: row.column_default,
157
+ comment: commentMap[row.column_name] ?? null
158
+ };
159
+ }
160
+ } else if (IS_SQLITE) {
161
+ const result = await sql`PRAGMA table_info(${sql(tableName)})`;
162
+ for (const row of result) {
163
+ let baseType = String(row.type || '').toUpperCase();
164
+ let length = null;
165
+ const m = /^(\w+)\s*\((\d+)\)/.exec(baseType);
166
+ if (m) {
167
+ baseType = m[1];
168
+ length = Number(m[2]);
169
+ }
170
+ columns[row.name] = {
171
+ type: baseType.toLowerCase(),
172
+ columnType: baseType.toLowerCase(),
173
+ length: length,
174
+ nullable: row.notnull === 0,
175
+ defaultValue: row.dflt_value,
176
+ comment: null
177
+ };
178
+ }
179
+ }
128
180
  return columns;
129
181
  };
130
182
 
131
- // 获取表的现有索引信息
132
- const getTableIndexes = async (client, tableName) => {
133
- const result = await exec(
134
- client,
135
- `SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS
136
- WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME != 'PRIMARY' ORDER BY INDEX_NAME`,
137
- [Env.MYSQL_DB || 'test', tableName]
138
- );
139
-
183
+ // 获取表的现有索引信息(单列索引)
184
+ const getTableIndexes = async (tableName) => {
140
185
  const indexes = {};
141
- result.forEach((row) => {
142
- if (!indexes[row.INDEX_NAME]) indexes[row.INDEX_NAME] = [];
143
- indexes[row.INDEX_NAME].push(row.COLUMN_NAME);
144
- });
186
+ if (IS_MYSQL) {
187
+ const result = await sql`
188
+ SELECT INDEX_NAME, COLUMN_NAME
189
+ FROM information_schema.STATISTICS
190
+ WHERE TABLE_SCHEMA = ${Env.DB_NAME}
191
+ AND TABLE_NAME = ${tableName}
192
+ AND INDEX_NAME != 'PRIMARY'
193
+ ORDER BY INDEX_NAME
194
+ `;
195
+ for (const row of result) {
196
+ if (!indexes[row.INDEX_NAME]) indexes[row.INDEX_NAME] = [];
197
+ indexes[row.INDEX_NAME].push(row.COLUMN_NAME);
198
+ }
199
+ } else if (IS_PG) {
200
+ const result = await sql`SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = ${tableName}`;
201
+ for (const row of result) {
202
+ const m = /\(([^)]+)\)/.exec(row.indexdef);
203
+ if (m) {
204
+ const col = m[1].replace(/\"/g, '').replace(/"/g, '').trim();
205
+ indexes[row.indexname] = [col];
206
+ }
207
+ }
208
+ } else if (IS_SQLITE) {
209
+ const list = await sql`PRAGMA index_list(${sql(tableName)})`;
210
+ for (const idx of list) {
211
+ const info = await sql`PRAGMA index_info(${sql(idx.name)})`;
212
+ const cols = info.map((r) => r.name);
213
+ if (cols.length === 1) indexes[idx.name] = cols;
214
+ }
215
+ }
145
216
  return indexes;
146
217
  };
147
218
 
148
- // 构建索引操作 SQL(统一使用 ALTER TABLE 并尽量在线)
219
+ // 构建索引操作 SQL(统一使用在线策略)
149
220
  const buildIndexSQL = (tableName, indexName, fieldName, action) => {
150
- const parts = [];
151
- if (action === 'create') {
152
- parts.push(`ADD INDEX \`${indexName}\` (\`${fieldName}\`)`);
153
- } else {
154
- parts.push(`DROP INDEX \`${indexName}\``);
155
- }
156
- if (FLAGS.ONLINE_INDEX) {
221
+ if (IS_MYSQL) {
222
+ const parts = [];
223
+ action === 'create' ? parts.push(`ADD INDEX \`${indexName}\` (\`${fieldName}\`)`) : parts.push(`DROP INDEX \`${indexName}\``);
224
+ // 始终使用在线算法
157
225
  parts.push('ALGORITHM=INPLACE');
158
226
  parts.push('LOCK=NONE');
227
+ return `ALTER TABLE \`${tableName}\` ${parts.join(', ')}`;
228
+ }
229
+ if (IS_PG) {
230
+ if (action === 'create') {
231
+ // 始终使用 CONCURRENTLY
232
+ return `CREATE INDEX CONCURRENTLY IF NOT EXISTS "${indexName}" ON "${tableName}"("${fieldName}")`;
233
+ }
234
+ return `DROP INDEX CONCURRENTLY IF EXISTS "${indexName}"`;
159
235
  }
160
- return `ALTER TABLE \`${tableName}\` ${parts.join(', ')}`;
236
+ // SQLite
237
+ if (action === 'create') return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${tableName}"("${fieldName}")`;
238
+ return `DROP INDEX IF EXISTS "${indexName}"`;
161
239
  };
162
240
 
163
- // 创建表
164
- const createTable = async (client, tableName, fields) => {
165
- const columns = [
166
- //
167
- '`id` BIGINT PRIMARY KEY COMMENT "主键ID"',
168
- '`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT "创建时间"',
169
- '`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT "更新时间"',
170
- '`deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT "删除时间"',
171
- '`state` BIGINT NOT NULL DEFAULT 0 COMMENT "状态字段"'
172
- ];
173
-
174
- const indexes = [
175
- //
176
- 'INDEX `idx_created_at` (`created_at`)',
177
- 'INDEX `idx_updated_at` (`updated_at`)',
178
- 'INDEX `idx_state` (`state`)'
179
- ];
180
-
181
- // 添加自定义字段和索引
182
- for (const [fieldName, rule] of Object.entries(fields)) {
183
- columns.push(getColumnDefinition(fieldName, rule));
184
-
185
- // 使用第6个属性判断是否设置索引
186
- const ruleParts = parseFieldRule(rule);
187
- const fieldHasIndex = ruleParts[5]; // 第6个属性
188
- if (fieldHasIndex === '1') {
189
- indexes.push(`INDEX \`idx_${fieldName}\` (\`${fieldName}\`)`);
190
- }
191
- }
192
-
193
- const createTableSQL = `
194
- CREATE TABLE \`${tableName}\` (
195
- ${columns.join(',\n ')},
196
- ${indexes.join(',\n ')}
197
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs
198
- `;
199
-
200
- if (FLAGS.DRY_RUN) {
201
- Logger.info(`[计划] ${createTableSQL.replace(/\n+/g, ' ')}`);
241
+ // 创建表(尽量精简但保持既有行为)
242
+ const createTable = async (tableName, fields) => {
243
+ // 统一列定义数组:包含系统字段与业务字段
244
+ const colDefs = [];
245
+
246
+ // 1) 固定字段
247
+ if (IS_MYSQL) {
248
+ colDefs.push('`id` BIGINT PRIMARY KEY COMMENT "主键ID"');
249
+ colDefs.push('`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT "创建时间"');
250
+ colDefs.push('`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT "更新时间"');
251
+ colDefs.push('`deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT "删除时间"');
252
+ colDefs.push('`state` BIGINT NOT NULL DEFAULT 0 COMMENT "状态字段"');
202
253
  } else {
203
- await exec(client, createTableSQL);
254
+ colDefs.push('"id" INTEGER PRIMARY KEY');
255
+ colDefs.push('"created_at" INTEGER NOT NULL DEFAULT 0');
256
+ colDefs.push('"updated_at" INTEGER NOT NULL DEFAULT 0');
257
+ colDefs.push('"deleted_at" INTEGER NOT NULL DEFAULT 0');
258
+ colDefs.push('"state" INTEGER NOT NULL DEFAULT 0');
259
+ }
260
+
261
+ // 2) 业务字段
262
+ for (const [fieldKey, fieldRule] of Object.entries(fields)) {
263
+ const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
264
+ const sqlType = ['string', 'array'].includes(fieldType) ? `${typeMapping[fieldType]}(${fieldMax})` : typeMapping[fieldType];
265
+ const defaultSql = ['number', 'string', 'array'].includes(fieldType) ? (isType(fieldDefault, 'number') ? ` DEFAULT ${fieldDefault}` : ` DEFAULT '${fieldDefault}'`) : '';
266
+ if (IS_MYSQL) {
267
+ colDefs.push(`\`${fieldKey}\` ${sqlType} NOT NULL${defaultSql} COMMENT "${String(fieldName).replace(/"/g, '\\"')}"`);
268
+ } else {
269
+ colDefs.push(`"${fieldKey}" ${sqlType} NOT NULL${defaultSql}`);
270
+ }
271
+ }
272
+
273
+ // 3) CREATE TABLE 语句
274
+ const cols = colDefs.join(',\n ');
275
+ let createSQL;
276
+ if (IS_MYSQL) {
277
+ createSQL = `CREATE TABLE \`${tableName}\` (\n ${cols}\n ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs`;
278
+ } else {
279
+ createSQL = `CREATE TABLE "${tableName}" (\n ${cols}\n )`;
280
+ }
281
+
282
+ if (IS_PLAN) {
283
+ Logger.info(`[计划] ${createSQL.replace(/\n+/g, ' ')}`);
284
+ } else {
285
+ await sql.unsafe(createSQL);
204
286
  Logger.info(`[新建表] ${tableName}`);
205
287
  }
288
+
289
+ // 4) PG: 列注释(SQLite 不支持;MySQL 已在列定义中)
290
+ if (IS_PG) {
291
+ const commentPairs = [
292
+ ['id', '主键ID'],
293
+ ['created_at', '创建时间'],
294
+ ['updated_at', '更新时间'],
295
+ ['deleted_at', '删除时间'],
296
+ ['state', '状态字段']
297
+ ];
298
+ for (const [name, cmt] of commentPairs) {
299
+ const stmt = `COMMENT ON COLUMN "${tableName}"."${name}" IS '${cmt}'`;
300
+ if (IS_PLAN) {
301
+ Logger.info(`[计划] ${stmt}`);
302
+ } else {
303
+ await sql.unsafe(stmt);
304
+ }
305
+ }
306
+ for (const [fieldKey, fieldRule] of Object.entries(fields)) {
307
+ const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
308
+ const stmt = `COMMENT ON COLUMN "${tableName}"."${fieldKey}" IS '${fieldName}'`;
309
+ if (IS_PLAN) {
310
+ Logger.info(`[计划] ${stmt}`);
311
+ } else {
312
+ await sql.unsafe(stmt);
313
+ }
314
+ }
315
+ }
316
+
317
+ // 5) 索引:系统字段 + 业务字段(按规则)
318
+ for (const sysField of ['created_at', 'updated_at', 'state']) {
319
+ const stmt = buildIndexSQL(tableName, `idx_${sysField}`, sysField, 'create');
320
+ if (IS_PLAN) {
321
+ Logger.info(`[计划] ${stmt}`);
322
+ } else {
323
+ await sql.unsafe(stmt);
324
+ }
325
+ }
326
+ for (const [fieldKey, fieldRule] of Object.entries(fields)) {
327
+ const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
328
+ if (fieldIndex === 1) {
329
+ const stmt = buildIndexSQL(tableName, `idx_${fieldKey}`, fieldKey, 'create');
330
+ if (IS_PLAN) {
331
+ Logger.info(`[计划] ${stmt}`);
332
+ } else {
333
+ await sql.unsafe(stmt);
334
+ }
335
+ }
336
+ }
206
337
  };
207
338
 
208
339
  // 比较字段定义变化
209
- const compareFieldDefinition = (existingColumn, newRule, fieldName) => {
210
- const ruleParts = parseFieldRule(newRule);
211
- const [fieldDisplayName, fieldType, fieldMin, fieldMaxLength, fieldDefaultValue] = ruleParts;
340
+ const compareFieldDefinition = (existingColumn, newRule, colName) => {
341
+ const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(newRule);
212
342
  const changes = [];
213
343
 
214
- // 检查长度变化(string和array类型)
215
- if (fieldType === 'string' || fieldType === 'array') {
216
- if (fieldMaxLength === 'null') {
217
- throw new Error(`string/array 类型字段的最大长度未设置,必须指定最大长度`);
218
- }
219
- const newMaxLength = parseInt(fieldMaxLength);
220
- if (existingColumn.length !== newMaxLength) {
221
- changes.push({ type: 'length', current: existingColumn.length, new: newMaxLength });
344
+ // 检查长度变化(string和array类型) - SQLite 不比较长度
345
+ if (!IS_SQLITE && (fieldType === 'string' || fieldType === 'array')) {
346
+ if (existingColumn.length !== fieldMax) {
347
+ changes.push({
348
+ type: 'length',
349
+ current: existingColumn.length,
350
+ new: fieldMax
351
+ });
222
352
  }
223
353
  }
224
354
 
225
- // 检查注释变化(使用第1个属性作为字段显示名称)
226
- if (fieldDisplayName && fieldDisplayName !== 'null') {
355
+ // 检查注释变化(MySQL/PG 支持列注释)
356
+ if (!IS_SQLITE) {
227
357
  const currentComment = existingColumn.comment || '';
228
- if (currentComment !== fieldDisplayName) {
229
- changes.push({ type: 'comment', current: currentComment, new: fieldDisplayName });
358
+ if (currentComment !== fieldName) {
359
+ changes.push({
360
+ type: 'comment',
361
+ current: currentComment,
362
+ new: fieldName
363
+ });
230
364
  }
231
365
  }
232
366
 
233
- // 检查数据类型变化
234
- const expectedDbType = {
235
- number: 'bigint',
236
- string: 'varchar',
237
- text: 'mediumtext',
238
- array: 'varchar'
239
- }[fieldType];
240
-
241
- if (existingColumn.type.toLowerCase() !== expectedDbType) {
242
- changes.push({ type: 'datatype', current: existingColumn.type, new: expectedDbType });
367
+ // 检查数据类型变化(按方言)
368
+ if (existingColumn.type.toLowerCase() !== typeMapping[fieldType].toLowerCase()) {
369
+ changes.push({
370
+ type: 'datatype',
371
+ current: existingColumn.type,
372
+ new: typeMapping[fieldType].toLowerCase()
373
+ });
243
374
  }
244
375
 
245
376
  // 检查默认值变化(按照生成规则推导期望默认值)
246
- const expectedDefault = getExpectedDefault(fieldType, fieldDefaultValue);
247
- const currDef = normalizeDefault(existingColumn.defaultValue);
248
- const newDef = normalizeDefault(expectedDefault);
249
- if (currDef !== newDef) {
250
- changes.push({ type: 'default', current: existingColumn.defaultValue, new: expectedDefault });
251
- }
252
-
253
- // 检查可空性变化(统一期望 NOT NULL)
254
- const expectedNullable = false; // 期望 NOT NULL
255
- if (existingColumn.nullable !== expectedNullable) {
256
- // existingColumn.nullable 为 true 表示可空
377
+ if (String(existingColumn.defaultValue) !== String(fieldDefault)) {
257
378
  changes.push({
258
- type: 'nullability',
259
- current: existingColumn.nullable ? 'NULL' : 'NOT NULL',
260
- new: expectedNullable ? 'NULL' : 'NOT NULL'
379
+ type: 'default',
380
+ current: existingColumn.defaultValue,
381
+ new: fieldDefault
261
382
  });
262
383
  }
263
384
 
264
- return { hasChanges: changes.length > 0, changes };
385
+ return changes;
265
386
  };
266
387
 
267
388
  // 生成字段 DDL 子句(不含 ALTER TABLE 前缀)
268
- const generateDDLClause = (fieldName, rule, isAdd = false) => {
269
- const columnDef = getColumnDefinition(fieldName, rule);
270
- const operation = isAdd ? 'ADD COLUMN' : 'MODIFY COLUMN';
271
- return `${operation} ${columnDef}`;
389
+ const generateDDLClause = (fieldKey, fieldRule, isAdd = false) => {
390
+ const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
391
+ const sqlType = ['string', 'array'].includes(fieldType) ? `${typeMapping[fieldType]}(${fieldMax})` : typeMapping[fieldType];
392
+ const defaultSql = ['number', 'string', 'array'].includes(fieldType) ? (isType(fieldDefault, 'number') ? ` DEFAULT ${fieldDefault}` : ` DEFAULT '${fieldDefault}'`) : '';
393
+ if (IS_MYSQL) {
394
+ return `${isAdd ? 'ADD COLUMN' : 'MODIFY COLUMN'} \`${fieldKey}\` ${sqlType} NOT NULL${defaultSql} COMMENT "${String(fieldName).replace(/"/g, '\\"')}"`;
395
+ }
396
+ if (IS_PG) {
397
+ if (isAdd) return `ADD COLUMN IF NOT EXISTS "${fieldKey}" ${sqlType} NOT NULL${defaultSql}`;
398
+ // PG 修改:类型与非空可分条执行,生成 TYPE 改变;非空另由上层统一控制
399
+ return `ALTER COLUMN "${fieldKey}" TYPE ${sqlType}`;
400
+ }
401
+ // SQLite 仅支持 ADD COLUMN(>=3.50.0:支持 IF NOT EXISTS)
402
+ if (isAdd) return `ADD COLUMN IF NOT EXISTS "${fieldKey}" ${sqlType} NOT NULL${defaultSql}`;
403
+ return '';
272
404
  };
273
405
 
274
406
  // 安全执行DDL语句
275
- const executeDDLSafely = async (client, sql) => {
407
+ const executeDDLSafely = async (stmt) => {
276
408
  try {
277
- await exec(client, sql);
409
+ await sql.unsafe(stmt);
278
410
  return true;
279
411
  } catch (error) {
280
- // INSTANT失败时尝试INPLACE
281
- if (sql.includes('ALGORITHM=INSTANT')) {
282
- const inplaceSql = sql.replace(/ALGORITHM=INSTANT/g, 'ALGORITHM=INPLACE');
412
+ // MySQL 专用降级路径
413
+ if (stmt.includes('ALGORITHM=INSTANT')) {
414
+ const inplaceSql = stmt.replace(/ALGORITHM=INSTANT/g, 'ALGORITHM=INPLACE');
283
415
  try {
284
- await exec(client, inplaceSql);
416
+ await sql.unsafe(inplaceSql);
285
417
  return true;
286
418
  } catch (inplaceError) {
287
419
  // 最后尝试传统DDL:移除 ALGORITHM/LOCK 附加子句后执行
288
- const traditionSql = sql
420
+ const traditionSql = stmt
289
421
  .replace(/,\s*ALGORITHM=INPLACE/g, '')
290
422
  .replace(/,\s*ALGORITHM=INSTANT/g, '')
291
423
  .replace(/,\s*LOCK=(NONE|SHARED|EXCLUSIVE)/g, '');
292
- await exec(client, traditionSql);
424
+ await sql.unsafe(traditionSql);
293
425
  return true;
294
426
  }
295
427
  } else {
@@ -298,276 +430,296 @@ const executeDDLSafely = async (client, sql) => {
298
430
  }
299
431
  };
300
432
 
433
+ // SQLite 重建表迁移(简化版:仅处理新增/修改字段,不处理复杂约束与复合索引)
434
+ const rebuildSqliteTable = async (tableName, fields) => {
435
+ // 1. 读取现有列顺序
436
+ const info = await sql`PRAGMA table_info(${sql(tableName)})`;
437
+ const existingCols = info.map((r) => r.name);
438
+ const targetCols = ['id', 'created_at', 'updated_at', 'deleted_at', 'state', ...Object.keys(fields)];
439
+ const tmpTable = `${tableName}__tmp__${Date.now()}`;
440
+
441
+ // 2. 创建新表(使用当前定义)
442
+ await createTable(tmpTable, fields);
443
+
444
+ // 3. 拷贝数据(按交集列)
445
+ const commonCols = targetCols.filter((c) => existingCols.includes(c));
446
+ if (commonCols.length > 0) {
447
+ const colsSql = commonCols.map((c) => `"${c}"`).join(', ');
448
+ await sql.unsafe(`INSERT INTO "${tmpTable}" (${colsSql}) SELECT ${colsSql} FROM "${tableName}"`);
449
+ }
450
+
451
+ // 4. 删除旧表并重命名
452
+ await sql.unsafe(`DROP TABLE "${tableName}"`);
453
+ await sql.unsafe(`ALTER TABLE "${tmpTable}" RENAME TO "${tableName}"`);
454
+ };
455
+
456
+ // 将表结构计划应用到数据库(执行 DDL/索引/注释等)
457
+ const applyTablePlan = async (tableName, fields, plan) => {
458
+ if (!plan || !plan.changed) return;
459
+
460
+ // SQLite: 仅支持部分 ALTER;需要时走重建
461
+ if (IS_SQLITE) {
462
+ if (plan.modifyClauses.length > 0 || plan.defaultClauses.length > 0) {
463
+ if (IS_PLAN) Logger.info(`[计划] 重建表 ${tableName} 以应用列修改/默认值变化`);
464
+ else await rebuildSqliteTable(tableName, fields);
465
+ } else {
466
+ for (const c of plan.addClauses) {
467
+ const stmt = `ALTER TABLE "${tableName}" ${c}`;
468
+ if (IS_PLAN) Logger.info(`[计划] ${stmt}`);
469
+ else await sql.unsafe(stmt);
470
+ }
471
+ }
472
+ } else {
473
+ const clauses = [...plan.addClauses, ...plan.modifyClauses];
474
+ if (clauses.length > 0) {
475
+ const suffix = IS_MYSQL ? ', ALGORITHM=INSTANT, LOCK=NONE' : '';
476
+ const stmt = IS_MYSQL ? `ALTER TABLE \`${tableName}\` ${clauses.join(', ')}${suffix}` : `ALTER TABLE "${tableName}" ${clauses.join(', ')}`;
477
+ if (IS_PLAN) Logger.info(`[计划] ${stmt}`);
478
+ else if (IS_MYSQL) await executeDDLSafely(stmt);
479
+ else await sql.unsafe(stmt);
480
+ }
481
+ }
482
+
483
+ // 默认值专用 ALTER(SQLite 不支持)
484
+ if (plan.defaultClauses.length > 0) {
485
+ if (IS_SQLITE) {
486
+ Logger.warn(`SQLite 不支持修改默认值,表 ${tableName} 的默认值变更已跳过`);
487
+ } else {
488
+ const suffix = IS_MYSQL ? ', ALGORITHM=INSTANT, LOCK=NONE' : '';
489
+ const stmt = IS_MYSQL ? `ALTER TABLE \`${tableName}\` ${plan.defaultClauses.join(', ')}${suffix}` : `ALTER TABLE "${tableName}" ${plan.defaultClauses.join(', ')}`;
490
+ if (IS_PLAN) Logger.info(`[计划] ${stmt}`);
491
+ else if (IS_MYSQL) await executeDDLSafely(stmt);
492
+ else await sql.unsafe(stmt);
493
+ }
494
+ }
495
+
496
+ // 索引操作
497
+ for (const act of plan.indexActions) {
498
+ const stmt = buildIndexSQL(tableName, act.indexName, act.fieldName, act.action);
499
+ if (IS_PLAN) {
500
+ Logger.info(`[计划] ${stmt}`);
501
+ } else {
502
+ try {
503
+ await sql.unsafe(stmt);
504
+ if (act.action === 'create') {
505
+ Logger.info(`[索引变化] 新建索引 ${tableName}.${act.indexName} (${act.fieldName})`);
506
+ } else {
507
+ Logger.info(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
508
+ }
509
+ } catch (error) {
510
+ Logger.error(`${act.action === 'create' ? '创建' : '删除'}索引失败: ${error.message}`);
511
+ throw error;
512
+ }
513
+ }
514
+ }
515
+
516
+ // PG 列注释
517
+ if (IS_PG && plan.commentActions && plan.commentActions.length > 0) {
518
+ for (const stmt of plan.commentActions) {
519
+ if (IS_PLAN) Logger.info(`[计划] ${stmt}`);
520
+ else await sql.unsafe(stmt);
521
+ }
522
+ }
523
+
524
+ // 计数
525
+ globalCount.modifiedTables++;
526
+ };
527
+
301
528
  // 同步表结构
302
- const syncTable = async (client, tableName, fields) => {
303
- const existingColumns = await getTableColumns(client, tableName);
304
- const existingIndexes = await getTableIndexes(client, tableName);
305
- const systemFields = ['id', 'created_at', 'updated_at', 'deleted_at', 'state'];
529
+ const modifyTable = async (tableName, fields) => {
530
+ const existingColumns = await getTableColumns(tableName);
531
+ const existingIndexes = await getTableIndexes(tableName);
306
532
  let changed = false;
307
533
 
308
534
  const addClauses = [];
309
535
  const modifyClauses = [];
310
536
  const defaultClauses = [];
311
537
  const indexActions = [];
312
- // 变更统计(按字段粒度)
313
- const changeStats = {
314
- addFields: 0,
315
- datatype: 0,
316
- length: 0,
317
- default: 0,
318
- comment: 0,
319
- nullability: 0,
320
- indexCreate: 0,
321
- indexDrop: 0
322
- };
323
-
324
- // 同步字段
325
- for (const [fieldName, rule] of Object.entries(fields)) {
326
- if (existingColumns[fieldName]) {
327
- const comparison = compareFieldDefinition(existingColumns[fieldName], rule, fieldName);
328
- if (comparison.hasChanges) {
329
- // 打印具体变动项并统计
330
- for (const c of comparison.changes) {
331
- const label = { length: '长度', datatype: '类型', comment: '注释', default: '默认值' }[c.type] || c.type;
332
- Logger.info(`[字段变更] ${tableName}.${fieldName} ${label}: ${c.current ?? 'NULL'} -> ${c.new ?? 'NULL'}`);
333
- if (c.type in changeStats) changeStats[c.type]++;
538
+
539
+ for (const [fieldKey, fieldRule] of Object.entries(fields)) {
540
+ if (existingColumns[fieldKey]) {
541
+ const comparison = compareFieldDefinition(existingColumns[fieldKey], fieldRule, fieldKey);
542
+ if (comparison.length > 0) {
543
+ for (const c of comparison) {
544
+ const label =
545
+ {
546
+ length: '长度',
547
+ datatype: '类型',
548
+ comment: '注释',
549
+ default: '默认值'
550
+ }[c.type] || c.type;
551
+ Logger.info(`[字段变更] ${tableName}.${fieldKey} ${label}: ${c.current ?? 'NULL'} -> ${c.new ?? 'NULL'}`);
552
+ // 全量计数:全局累加
553
+ if (c.type === 'datatype') globalCount.typeChanges++;
554
+ else if (c.type === 'length') globalCount.maxChanges++;
555
+ else if (c.type === 'default') globalCount.defaultChanges++;
556
+ else if (c.type === 'comment') globalCount.nameChanges++;
334
557
  }
335
- // 风险护栏:长度收缩/类型变更
336
- const ruleParts = parseFieldRule(rule);
337
- const [, fType, , fMax, fDef] = ruleParts;
338
- if ((fType === 'string' || fType === 'array') && existingColumns[fieldName].length && fMax !== 'null') {
339
- const newLen = parseInt(fMax);
340
- if (existingColumns[fieldName].length > newLen && FLAGS.DISALLOW_SHRINK) {
341
- Logger.warn(`[跳过危险变更] ${tableName}.${fieldName} 长度收缩 ${existingColumns[fieldName].length} -> ${newLen} 已被跳过(设置 SYNC_DISALLOW_SHRINK=0 可放开)`);
342
- // 如果仅有 shrink 一个变化,仍可能还有默认/注释变化要处理
558
+ const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
559
+ if ((fieldType === 'string' || fieldType === 'array') && existingColumns[fieldKey].length) {
560
+ if (existingColumns[fieldKey].length > fieldMax) {
561
+ Logger.warn(`[跳过危险变更] ${tableName}.${fieldKey} 长度收缩 ${existingColumns[fieldKey].length} -> ${fieldMax} 已被跳过(设置 SYNC_DISALLOW_SHRINK=0 可放开)`);
343
562
  }
344
563
  }
345
- const expectedDbType = {
346
- number: 'bigint',
347
- string: 'varchar',
348
- text: 'mediumtext',
349
- array: 'varchar'
350
- }[parseFieldRule(rule)[1]];
351
- if (existingColumns[fieldName].type.toLowerCase() !== expectedDbType && !FLAGS.ALLOW_TYPE_CHANGE) {
352
- Logger.warn(`[跳过危险变更] ${tableName}.${fieldName} 类型变更 ${existingColumns[fieldName].type} -> ${expectedDbType} 已被跳过(设置 SYNC_ALLOW_TYPE_CHANGE=1 可放开)`);
353
- // 继续处理默认值/注释等非类型变更
564
+ const hasTypeChange = comparison.some((c) => c.type === 'datatype');
565
+ const hasLengthChange = comparison.some((c) => c.type === 'length');
566
+ const onlyDefaultChanged = comparison.every((c) => c.type === 'default');
567
+ const defaultChanged = comparison.some((c) => c.type === 'default');
568
+
569
+ // 严格限制:除 string/array 互转外,禁止任何字段类型变更;一旦发现,立即终止同步
570
+ // 说明:string array 在各方言下映射同为 VARCHAR/character varying/TEXT,compare 不会将其视为类型变更
571
+ if (hasTypeChange) {
572
+ const currentSqlType = String(existingColumns[fieldKey].type || '').toLowerCase();
573
+ const newSqlType = String(typeMapping[fieldType] || '').toLowerCase();
574
+ // 明确抛错,阻止后续任何 DDL 应用
575
+ throw new Error(`禁止字段类型变更: ${tableName}.${fieldKey} ${currentSqlType} -> ${newSqlType}。仅允许 string<->array 互相切换`);
354
576
  }
355
577
 
356
- // 判断是否“仅默认值变化”
357
- const onlyDefaultChanged = comparison.changes.every((c) => c.type === 'default');
358
- if (onlyDefaultChanged) {
359
- const expectedDefault = getExpectedDefault(parseFieldRule(rule)[1], parseFieldRule(rule)[4]);
360
- if (expectedDefault === null) {
361
- defaultClauses.push(`ALTER COLUMN \`${fieldName}\` DROP DEFAULT`);
362
- } else {
363
- const isNumber = parseFieldRule(rule)[1] === 'number';
364
- const v = isNumber ? expectedDefault : `"${String(expectedDefault).replace(/"/g, '\\"')}"`;
365
- defaultClauses.push(`ALTER COLUMN \`${fieldName}\` SET DEFAULT ${v}`);
366
- }
367
- } else {
368
- // 判断是否需要跳过 MODIFY:包含收缩或类型变更时跳过
369
- let skipModify = false;
370
- const hasLengthChange = comparison.changes.some((c) => c.type === 'length');
371
- if (hasLengthChange && (fType === 'string' || fType === 'array') && existingColumns[fieldName].length && fMax !== 'null') {
372
- const newLen = parseInt(fMax);
373
- if (existingColumns[fieldName].length > newLen && FLAGS.DISALLOW_SHRINK) {
374
- skipModify = true;
578
+ // 默认值变化处理:
579
+ if (defaultChanged) {
580
+ const v = fieldType === 'number' ? fieldDefault : `'${fieldDefault}'`;
581
+ if (IS_PG) {
582
+ defaultClauses.push(`ALTER COLUMN "${fieldKey}" SET DEFAULT ${v}`);
583
+ } else if (IS_MYSQL && onlyDefaultChanged) {
584
+ // MySQL 的 TEXT/BLOB 不允许 DEFAULT,跳过 text 类型
585
+ if (fieldType !== 'text') {
586
+ defaultClauses.push(`ALTER COLUMN \`${fieldKey}\` SET DEFAULT ${v}`);
375
587
  }
376
588
  }
377
- const hasTypeChange = comparison.changes.some((c) => c.type === 'datatype');
378
- if (hasTypeChange && !FLAGS.ALLOW_TYPE_CHANGE) {
379
- skipModify = true;
589
+ }
590
+
591
+ // 若不仅仅是默认值变化,继续生成修改子句
592
+ if (!onlyDefaultChanged) {
593
+ let skipModify = false;
594
+ if (hasLengthChange && (fieldType === 'string' || fieldType === 'array') && existingColumns[fieldKey].length) {
595
+ const oldLen = existingColumns[fieldKey].length;
596
+ const isShrink = oldLen > fieldMax;
597
+ if (isShrink) skipModify = true;
380
598
  }
381
- if (!skipModify) {
382
- // 合并到 MODIFY COLUMN 子句
383
- modifyClauses.push(generateDDLClause(fieldName, rule, false));
599
+ if (hasTypeChange) {
600
+ if (IS_PG && isPgCompatibleTypeChange(existingColumns[fieldKey].type, typeMapping[fieldType].toLowerCase())) {
601
+ Logger.info(`[PG兼容类型变更] ${tableName}.${fieldKey} ${existingColumns[fieldKey].type} -> ${typeMapping[fieldType].toLowerCase()} 允许执行`);
602
+ }
384
603
  }
604
+ if (!skipModify) modifyClauses.push(generateDDLClause(fieldKey, fieldRule, false));
385
605
  }
386
606
  changed = true;
387
607
  }
388
608
  } else {
389
- // 新增字段日志
390
- const [disp, fType, fMin, fMax, fDef, fIdx] = parseFieldRule(rule);
391
- const lenPart = fType === 'string' || fType === 'array' ? ` 长度:${parseInt(fMax)}` : '';
392
- const expectedDefault = getExpectedDefault(fType, fDef);
393
- Logger.info(`[新增字段] ${tableName}.${fieldName} 类型:${fType}${lenPart} 默认:${expectedDefault ?? 'NULL'}`);
394
- addClauses.push(generateDDLClause(fieldName, rule, true));
609
+ const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
610
+ const lenPart = fieldType === 'string' || fieldType === 'array' ? ` 长度:${parseInt(fieldMax)}` : '';
611
+ Logger.info(`[新增字段] ${tableName}.${fieldKey} 类型:${fieldType}${lenPart} 默认:${fieldDefault ?? 'NULL'}`);
612
+ addClauses.push(generateDDLClause(fieldKey, fieldRule, true));
395
613
  changed = true;
396
- changeStats.addFields++;
614
+ globalCount.addFields++;
397
615
  }
398
616
  }
399
617
 
400
- // 同步索引
401
- for (const [fieldName, rule] of Object.entries(fields)) {
402
- const ruleParts = parseFieldRule(rule);
403
- const fieldHasIndex = ruleParts[5]; // 使用第6个属性判断是否设置索引
404
- const indexName = `idx_${fieldName}`;
405
-
406
- if (fieldHasIndex === '1' && !existingIndexes[indexName]) {
407
- indexActions.push({ action: 'create', indexName, fieldName });
618
+ for (const sysField of ['created_at', 'updated_at', 'state']) {
619
+ const idxName = `idx_${sysField}`;
620
+ if (!existingIndexes[idxName]) {
621
+ indexActions.push({ action: 'create', indexName: idxName, fieldName: sysField });
622
+ changed = true;
623
+ globalCount.indexCreate++;
624
+ }
625
+ }
626
+ for (const [fieldKey, fieldRule] of Object.entries(fields)) {
627
+ const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
628
+ const indexName = `idx_${fieldKey}`;
629
+ if (fieldIndex === 1 && !existingIndexes[indexName]) {
630
+ indexActions.push({ action: 'create', indexName, fieldName: fieldKey });
408
631
  changed = true;
409
- changeStats.indexCreate++;
410
- } else if (fieldHasIndex !== '1' && existingIndexes[indexName] && existingIndexes[indexName].length === 1) {
411
- indexActions.push({ action: 'drop', indexName, fieldName });
632
+ globalCount.indexCreate++;
633
+ } else if (!(fieldIndex === 1) && existingIndexes[indexName] && existingIndexes[indexName].length === 1) {
634
+ indexActions.push({ action: 'drop', indexName, fieldName: fieldKey });
412
635
  changed = true;
413
- changeStats.indexDrop++;
636
+ globalCount.indexDrop++;
637
+ }
638
+ }
639
+
640
+ const commentActions = [];
641
+ if (IS_PG) {
642
+ for (const [fieldKey, fieldRule] of Object.entries(fields)) {
643
+ if (existingColumns[fieldKey]) {
644
+ const [fieldName, fieldType, fieldMin, fieldMax, fieldDefault, fieldIndex, fieldRegx] = parseRule(fieldRule);
645
+ const curr = existingColumns[fieldKey].comment || '';
646
+ const want = fieldName && fieldName !== 'null' ? String(fieldName) : '';
647
+ if (want !== curr) {
648
+ commentActions.push(`COMMENT ON COLUMN "${tableName}"."${fieldKey}" IS ${want ? `'${want}'` : 'NULL'}`);
649
+ changed = true;
650
+ }
651
+ }
414
652
  }
415
653
  }
416
- return { changed, addClauses, modifyClauses, defaultClauses, indexActions, metrics: changeStats };
654
+ // 仅当存在实际动作时才认为有变更(避免仅日志的收缩跳过被计为修改)
655
+ changed = addClauses.length > 0 || modifyClauses.length > 0 || defaultClauses.length > 0 || indexActions.length > 0 || commentActions.length > 0;
656
+ const plan = { changed, addClauses, modifyClauses, defaultClauses, indexActions, commentActions };
657
+ // 将计划应用(包含 --plan 情况下仅输出)
658
+ if (plan.changed) {
659
+ await applyTablePlan(tableName, fields, plan);
660
+ }
661
+ return plan;
417
662
  };
418
663
 
419
664
  // 主同步函数
420
665
  const SyncDb = async () => {
421
- let client = null;
422
-
423
666
  try {
424
667
  Logger.info('开始数据库表结构同步...');
668
+ // 重置全局统计,避免多次调用累加
669
+ for (const k of Object.keys(globalCount)) {
670
+ if (typeof globalCount[k] === 'number') globalCount[k] = 0;
671
+ }
425
672
 
426
673
  // 验证表定义文件
427
- const tableValidationResult = await checkTable();
428
- if (!tableValidationResult) {
674
+ if (!(await checkTable())) {
429
675
  throw new Error('表定义验证失败');
430
676
  }
431
677
 
432
- // 建立数据库连接并检查版本(统一工具函数)
433
- client = await createSqlClient({ max: 1 });
434
- const result = await client`SELECT VERSION() AS version`;
435
- const version = result[0].version;
436
-
437
- if (version.toLowerCase().includes('mariadb')) {
438
- throw new Error('此脚本仅支持 MySQL 8.0+,不支持 MariaDB');
439
- }
440
-
441
- const majorVersion = parseInt(version.split('.')[0]);
442
- if (majorVersion < 8) {
443
- throw new Error(`此脚本仅支持 MySQL 8.0+,当前版本: ${version}`);
444
- }
445
-
446
- Logger.info(`MySQL 版本检查通过: ${version}`);
678
+ // 建立数据库连接并检查版本(按方言)
679
+ // 在顶层也保留 sql 引用,便于未来需要跨函数访问
680
+ sql = await createSqlClient({ max: 1 });
681
+ await ensureDbVersion();
447
682
 
448
683
  // 扫描并处理表文件
449
684
  const tablesGlob = new Bun.Glob('*.json');
450
685
  const directories = [__dirtables, getProjectDir('tables')];
451
- let processedCount = 0;
452
- let createdTables = 0;
453
- let modifiedTables = 0;
454
- // 全局统计
455
- const overall = {
456
- addFields: 0,
457
- typeChanges: 0,
458
- maxChanges: 0, // 映射为长度变化
459
- minChanges: 0, // 最小值不参与 DDL,比对保留为0
460
- defaultChanges: 0,
461
- nameChanges: 0, // 字段显示名(注释)变更
462
- indexCreate: 0,
463
- indexDrop: 0
464
- };
686
+ // 统计使用全局 globalCount
465
687
 
466
688
  for (const dir of directories) {
467
- try {
468
- for await (const file of tablesGlob.scan({ cwd: dir, absolute: true, onlyFiles: true })) {
469
- const tableName = path.basename(file, '.json');
470
- const tableDefinition = await Bun.file(file).json();
471
- const result = await exec(client, 'SELECT COUNT(*) as count FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?', [Env.MYSQL_DB || 'test', tableName]);
472
- const exists = result[0].count > 0;
473
-
474
- if (exists) {
475
- const plan = await syncTable(client, tableName, tableDefinition);
476
- if (plan.changed) {
477
- // 汇总统计
478
- if (plan.metrics) {
479
- overall.addFields += plan.metrics.addFields;
480
- overall.typeChanges += plan.metrics.datatype;
481
- overall.maxChanges += plan.metrics.length;
482
- overall.defaultChanges += plan.metrics.default;
483
- overall.indexCreate += plan.metrics.indexCreate;
484
- overall.indexDrop += plan.metrics.indexDrop;
485
- overall.nameChanges += plan.metrics.comment;
486
- }
487
- // 合并执行 ALTER TABLE 子句
488
- if (FLAGS.MERGE_ALTER) {
489
- const clauses = [...plan.addClauses, ...plan.modifyClauses];
490
- if (clauses.length > 0) {
491
- const sql = `ALTER TABLE \`${tableName}\` ${clauses.join(', ')}, ALGORITHM=INSTANT, LOCK=NONE`;
492
- if (FLAGS.DRY_RUN) {
493
- Logger.info(`[计划] ${sql}`);
494
- } else {
495
- await executeDDLSafely(client, sql);
496
- }
497
- }
498
- } else {
499
- // 分别执行
500
- for (const c of plan.addClauses) {
501
- const sql = `ALTER TABLE \`${tableName}\` ${c}, ALGORITHM=INSTANT, LOCK=NONE`;
502
- if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
503
- else await executeDDLSafely(client, sql);
504
- }
505
- for (const c of plan.modifyClauses) {
506
- const sql = `ALTER TABLE \`${tableName}\` ${c}, ALGORITHM=INSTANT, LOCK=NONE`;
507
- if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
508
- else await executeDDLSafely(client, sql);
509
- }
510
- }
511
-
512
- // 默认值专用 ALTER
513
- if (plan.defaultClauses.length > 0) {
514
- const sql = `ALTER TABLE \`${tableName}\` ${plan.defaultClauses.join(', ')}, ALGORITHM=INSTANT, LOCK=NONE`;
515
- if (FLAGS.DRY_RUN) Logger.info(`[计划] ${sql}`);
516
- else await executeDDLSafely(client, sql);
517
- }
518
-
519
- // 索引操作
520
- for (const act of plan.indexActions) {
521
- const sql = buildIndexSQL(tableName, act.indexName, act.fieldName, act.action);
522
- if (FLAGS.DRY_RUN) {
523
- Logger.info(`[计划] ${sql}`);
524
- } else {
525
- try {
526
- await exec(client, sql);
527
- if (act.action === 'create') {
528
- Logger.info(`[索引变化] 新建索引 ${tableName}.${act.indexName} (${act.fieldName})`);
529
- } else {
530
- Logger.info(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
531
- }
532
- } catch (error) {
533
- Logger.error(`${act.action === 'create' ? '创建' : '删除'}索引失败: ${error.message}`);
534
- throw error;
535
- }
536
- }
537
- }
538
-
539
- modifiedTables++;
540
- }
541
- } else {
542
- await createTable(client, tableName, tableDefinition);
543
- createdTables++;
544
- // 新建表已算作变更
545
- modifiedTables += 0;
546
- // 创建表统计:按需求仅汇总创建表数量
547
- }
689
+ for await (const file of tablesGlob.scan({ cwd: dir, absolute: true, onlyFiles: true })) {
690
+ const tableName = toSnakeTableName(path.basename(file, '.json'));
691
+ const tableDefinition = await Bun.file(file).json();
692
+ const existsTable = await tableExists(tableName);
548
693
 
549
- processedCount++;
694
+ if (existsTable) {
695
+ await modifyTable(tableName, tableDefinition);
696
+ } else {
697
+ await createTable(tableName, tableDefinition);
698
+ globalCount.createdTables++;
550
699
  }
551
- } catch (error) {
552
- Logger.warn(`扫描目录 ${dir} 出错: ${error.message}`);
700
+ globalCount.processedTables++;
553
701
  }
554
702
  }
555
703
 
556
704
  // 显示统计信息(扩展维度)
557
- Logger.info(`统计 - 创建表: ${createdTables}`);
558
- Logger.info(`统计 - 字段新增: ${overall.addFields}`);
559
- Logger.info(`统计 - 字段名称变更: ${overall.nameChanges}`);
560
- Logger.info(`统计 - 字段类型变更: ${overall.typeChanges}`);
561
- Logger.info(`统计 - 字段最小值变更: ${overall.minChanges}`);
562
- Logger.info(`统计 - 字段最大值变更: ${overall.maxChanges}`);
563
- Logger.info(`统计 - 字段默认值变更: ${overall.defaultChanges}`);
705
+ Logger.info(`统计 - 处理表总数: ${globalCount.processedTables}`);
706
+ Logger.info(`统计 - 创建表: ${globalCount.createdTables}`);
707
+ Logger.info(`统计 - 修改表: ${globalCount.modifiedTables}`);
708
+ Logger.info(`统计 - 字段新增: ${globalCount.addFields}`);
709
+ Logger.info(`统计 - 字段名称变更: ${globalCount.nameChanges}`);
710
+ Logger.info(`统计 - 字段类型变更: ${globalCount.typeChanges}`);
711
+ Logger.info(`统计 - 字段最小值变更: ${globalCount.minChanges}`);
712
+ Logger.info(`统计 - 字段最大值变更: ${globalCount.maxChanges}`);
713
+ Logger.info(`统计 - 字段默认值变更: ${globalCount.defaultChanges}`);
564
714
  // 索引新增/删除分别打印
565
- Logger.info(`统计 - 索引新增: ${overall.indexCreate}`);
566
- Logger.info(`统计 - 索引删除: ${overall.indexDrop}`);
715
+ Logger.info(`统计 - 索引新增: ${globalCount.indexCreate}`);
716
+ Logger.info(`统计 - 索引删除: ${globalCount.indexDrop}`);
567
717
 
568
- if (processedCount === 0) {
718
+ if (globalCount.processedTables === 0) {
569
719
  Logger.warn('没有找到任何表定义文件');
570
720
  }
721
+
722
+ // 保持单一职责:此处不再触发开发管理员同步
571
723
  } catch (error) {
572
724
  Logger.error(`数据库同步失败: ${error.message}`);
573
725
  Logger.error(`错误详情: ${error.stack}`);
@@ -579,9 +731,9 @@ const SyncDb = async () => {
579
731
  }
580
732
  process.exit(1);
581
733
  } finally {
582
- if (client) {
734
+ if (sql) {
583
735
  try {
584
- await client.close();
736
+ await sql.close();
585
737
  } catch (error) {
586
738
  Logger.warn('关闭数据库连接时出错:', error.message);
587
739
  }
@@ -589,8 +741,8 @@ const SyncDb = async () => {
589
741
  }
590
742
  };
591
743
 
592
- // 如果直接运行此脚本
593
- if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('dbSync.js')) {
744
+ // 如果直接运行此脚本(Bun 支持 import.meta.main)
745
+ if (import.meta.main) {
594
746
  SyncDb().catch((error) => {
595
747
  console.error('❌ 数据库同步失败:', error);
596
748
  process.exit(1);