befly 3.9.1 → 3.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "befly",
3
- "version": "3.9.1",
3
+ "version": "3.9.3",
4
4
  "description": "Befly - 为 Bun 专属打造的 TypeScript API 接口框架核心引擎",
5
5
  "type": "module",
6
6
  "private": false,
@@ -73,7 +73,7 @@
73
73
  "pino": "^10.1.0",
74
74
  "pino-roll": "^4.0.0"
75
75
  },
76
- "gitHead": "506400b7cc501bc1d4be3c2af76f2275172dacd8",
76
+ "gitHead": "4baa6420da1d505fb6a7cd82169d597456d9a99f",
77
77
  "devDependencies": {
78
78
  "typescript": "^5.9.3"
79
79
  }
@@ -60,16 +60,39 @@ export function buildIndexSQL(tableName: string, indexName: string, fieldName: s
60
60
  return `DROP INDEX IF EXISTS ${indexQuoted}`;
61
61
  }
62
62
 
63
+ /**
64
+ * 获取单个系统字段的列定义(用于 ADD COLUMN 或 CREATE TABLE)
65
+ *
66
+ * @param fieldName - 系统字段名(id, created_at, updated_at, deleted_at, state)
67
+ * @returns 列定义字符串,如果不是系统字段则返回 null
68
+ */
69
+ export function getSystemColumnDef(fieldName: string): string | null {
70
+ const mysqlDefs: Record<string, string> = {
71
+ id: '`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT "主键ID"',
72
+ created_at: '`created_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "创建时间"',
73
+ updated_at: '`updated_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "更新时间"',
74
+ deleted_at: '`deleted_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "删除时间"',
75
+ state: '`state` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "状态字段"'
76
+ };
77
+ const pgDefs: Record<string, string> = {
78
+ id: '"id" INTEGER PRIMARY KEY',
79
+ created_at: '"created_at" INTEGER NOT NULL DEFAULT 0',
80
+ updated_at: '"updated_at" INTEGER NOT NULL DEFAULT 0',
81
+ deleted_at: '"deleted_at" INTEGER NOT NULL DEFAULT 0',
82
+ state: '"state" INTEGER NOT NULL DEFAULT 0'
83
+ };
84
+
85
+ const defs = isMySQL() ? mysqlDefs : pgDefs;
86
+ return defs[fieldName] || null;
87
+ }
88
+
63
89
  /**
64
90
  * 构建系统字段列定义
65
91
  *
66
92
  * @returns 系统字段的列定义数组
67
93
  */
68
94
  export function buildSystemColumnDefs(): string[] {
69
- if (isMySQL()) {
70
- return ['`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT "主键ID"', '`created_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "创建时间"', '`updated_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "更新时间"', '`deleted_at` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "删除时间"', '`state` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT "状态字段"'];
71
- }
72
- return ['"id" INTEGER PRIMARY KEY', '"created_at" INTEGER NOT NULL DEFAULT 0', '"updated_at" INTEGER NOT NULL DEFAULT 0', '"deleted_at" INTEGER NOT NULL DEFAULT 0', '"state" INTEGER NOT NULL DEFAULT 0'];
95
+ return [getSystemColumnDef('id')!, getSystemColumnDef('created_at')!, getSystemColumnDef('updated_at')!, getSystemColumnDef('deleted_at')!, getSystemColumnDef('state')!];
73
96
  }
74
97
 
75
98
  /**
@@ -180,17 +203,50 @@ export async function executeDDLSafely(sql: SQL, stmt: string): Promise<boolean>
180
203
  }
181
204
 
182
205
  /**
183
- * PG 兼容类型变更识别:无需数据重写的宽化型变更
206
+ * 判断是否为兼容的类型变更(宽化型变更,无数据丢失风险)
184
207
  *
185
- * @param currentType - 当前类型
186
- * @param newType - 新类型
208
+ * 允许的变更:
209
+ * - MySQL: INT -> BIGINT, TINYINT -> INT/BIGINT, etc.
210
+ * - MySQL: VARCHAR -> TEXT/MEDIUMTEXT
211
+ * - PG: INTEGER -> BIGINT
212
+ * - PG: VARCHAR -> TEXT
213
+ *
214
+ * @param currentType - 当前数据库中的类型
215
+ * @param newType - 目标类型
187
216
  * @returns 是否为兼容变更
188
217
  */
189
- export function isPgCompatibleTypeChange(currentType: string, newType: string): boolean {
218
+ export function isCompatibleTypeChange(currentType: string, newType: string): boolean {
190
219
  const c = String(currentType || '').toLowerCase();
191
220
  const n = String(newType || '').toLowerCase();
192
- // varchar -> text 视为宽化
193
- if (c === 'character varying' && n === 'text') return true;
194
- // text -> character varying 非宽化(可能截断),不兼容
221
+
222
+ // 相同类型不算变更
223
+ if (c === n) return false;
224
+
225
+ // 提取基础类型(去掉 unsigned、长度等修饰)
226
+ const extractBaseType = (t: string): string => {
227
+ // 移除 unsigned 和括号内容
228
+ return t
229
+ .replace(/\s*unsigned/gi, '')
230
+ .replace(/\([^)]*\)/g, '')
231
+ .trim();
232
+ };
233
+
234
+ const cBase = extractBaseType(c);
235
+ const nBase = extractBaseType(n);
236
+
237
+ // MySQL/通用 整数类型宽化(小 -> 大)
238
+ const intTypes = ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint'];
239
+ const cIntIdx = intTypes.indexOf(cBase);
240
+ const nIntIdx = intTypes.indexOf(nBase);
241
+ if (cIntIdx !== -1 && nIntIdx !== -1 && nIntIdx > cIntIdx) {
242
+ return true;
243
+ }
244
+
245
+ // 字符串类型宽化
246
+ // MySQL: varchar -> text/mediumtext/longtext
247
+ if (cBase === 'varchar' && (nBase === 'text' || nBase === 'mediumtext' || nBase === 'longtext')) return true;
248
+ // PG: character varying -> text
249
+ if (cBase === 'character varying' && nBase === 'text') return true;
250
+
195
251
  return false;
196
252
  }
@@ -9,9 +9,9 @@
9
9
 
10
10
  import { snakeCase } from 'es-toolkit/string';
11
11
  import { Logger } from '../../lib/logger.js';
12
- import { isMySQL, isPG, CHANGE_TYPE_LABELS, getTypeMapping } from './constants.js';
12
+ import { isMySQL, isPG, CHANGE_TYPE_LABELS, getTypeMapping, SYSTEM_INDEX_FIELDS } from './constants.js';
13
13
  import { logFieldChange, resolveDefaultValue, generateDefaultSql, isStringOrArrayType } from './helpers.js';
14
- import { generateDDLClause, isPgCompatibleTypeChange } from './ddl.js';
14
+ import { generateDDLClause, getSystemColumnDef, isCompatibleTypeChange } from './ddl.js';
15
15
  import { getTableColumns, getTableIndexes } from './schema.js';
16
16
  import { compareFieldDefinition, applyTablePlan } from './apply.js';
17
17
  import type { TablePlan } from '../../types/sync.js';
@@ -71,11 +71,18 @@ export async function modifyTable(sql: SQL, tableName: string, fields: Record<st
71
71
  const onlyDefaultChanged = comparison.every((c) => c.type === 'default');
72
72
  const defaultChanged = comparison.some((c) => c.type === 'default');
73
73
 
74
- // 严格限制:除 string/array 互转外,禁止任何字段类型变更
74
+ // 类型变更检查:只允许兼容的宽化型变更(如 INT -> BIGINT)
75
75
  if (hasTypeChange) {
76
76
  const typeChange = comparison.find((c) => c.type === 'datatype');
77
- const errorMsg = [`禁止字段类型变更: ${tableName}.${dbFieldName}`, `当前类型: ${typeChange?.current}`, `目标类型: ${typeChange?.expected}`, `说明: 仅允许 string<->array 互相切换,其他类型变更需要手动处理`].join('\n');
78
- throw new Error(errorMsg);
77
+ const currentType = String(typeChange?.current || '').toLowerCase();
78
+ const typeMapping = getTypeMapping();
79
+ const expectedType = typeMapping[fieldDef.type]?.toLowerCase() || '';
80
+
81
+ if (!isCompatibleTypeChange(currentType, expectedType)) {
82
+ const errorMsg = [`禁止字段类型变更: ${tableName}.${dbFieldName}`, `当前类型: ${typeChange?.current}`, `目标类型: ${typeChange?.expected}`, `说明: 仅允许宽化型变更(如 INT->BIGINT, VARCHAR->TEXT),其他类型变更需要手动处理`].join('\n');
83
+ throw new Error(errorMsg);
84
+ }
85
+ Logger.debug(`[兼容类型变更] ${tableName}.${dbFieldName} ${currentType} -> ${expectedType}`);
79
86
  }
80
87
 
81
88
  // 默认值变化处理
@@ -111,13 +118,6 @@ export async function modifyTable(sql: SQL, tableName: string, fields: Record<st
111
118
  if (isShrink && !force) skipModify = true;
112
119
  }
113
120
 
114
- if (hasTypeChange) {
115
- const typeMapping = getTypeMapping();
116
- if (isPG() && isPgCompatibleTypeChange(existingColumns[dbFieldName].type, typeMapping[fieldDef.type].toLowerCase())) {
117
- Logger.debug(`[PG兼容类型变更] ${tableName}.${dbFieldName} ${existingColumns[dbFieldName].type} -> ${typeMapping[fieldDef.type].toLowerCase()} 允许执行`);
118
- }
119
- }
120
-
121
121
  if (!skipModify) modifyClauses.push(generateDDLClause(fieldKey, fieldDef, false));
122
122
  }
123
123
  changed = true;
@@ -130,10 +130,26 @@ export async function modifyTable(sql: SQL, tableName: string, fields: Record<st
130
130
  }
131
131
  }
132
132
 
133
- // 检查系统字段索引
134
- for (const sysField of ['created_at', 'updated_at', 'state']) {
133
+ // 检查并添加缺失的系统字段(created_at, updated_at, deleted_at, state)
134
+ // 注意:id 是主键,不会缺失;这里只处理可能缺失的其他系统字段
135
+ const systemFieldNames = ['created_at', 'updated_at', 'deleted_at', 'state'];
136
+ for (const sysFieldName of systemFieldNames) {
137
+ if (!existingColumns[sysFieldName]) {
138
+ const colDef = getSystemColumnDef(sysFieldName);
139
+ if (colDef) {
140
+ Logger.debug(` + 新增系统字段 ${sysFieldName}`);
141
+ addClauses.push(`ADD COLUMN ${colDef}`);
142
+ changed = true;
143
+ }
144
+ }
145
+ }
146
+
147
+ // 检查系统字段索引(字段存在或即将被添加时才创建索引)
148
+ for (const sysField of SYSTEM_INDEX_FIELDS) {
135
149
  const idxName = `idx_${sysField}`;
136
- if (!existingIndexes[idxName]) {
150
+ // 字段已存在或刚添加到 addClauses 中
151
+ const fieldWillExist = existingColumns[sysField] || systemFieldNames.includes(sysField);
152
+ if (fieldWillExist && !existingIndexes[idxName]) {
137
153
  indexActions.push({ action: 'create', indexName: idxName, fieldName: sysField });
138
154
  changed = true;
139
155
  }
@@ -6,7 +6,7 @@
6
6
  * - buildSystemColumnDefs
7
7
  * - buildBusinessColumnDefs
8
8
  * - generateDDLClause
9
- * - isPgCompatibleTypeChange
9
+ * - isCompatibleTypeChange
10
10
  */
11
11
 
12
12
  import { describe, test, expect, beforeAll } from 'bun:test';
@@ -19,7 +19,7 @@ let buildIndexSQL: any;
19
19
  let buildSystemColumnDefs: any;
20
20
  let buildBusinessColumnDefs: any;
21
21
  let generateDDLClause: any;
22
- let isPgCompatibleTypeChange: any;
22
+ let isCompatibleTypeChange: any;
23
23
 
24
24
  beforeAll(async () => {
25
25
  const ddl = await import('../sync/syncDb/ddl.js');
@@ -27,7 +27,7 @@ beforeAll(async () => {
27
27
  buildSystemColumnDefs = ddl.buildSystemColumnDefs;
28
28
  buildBusinessColumnDefs = ddl.buildBusinessColumnDefs;
29
29
  generateDDLClause = ddl.generateDDLClause;
30
- isPgCompatibleTypeChange = ddl.isPgCompatibleTypeChange;
30
+ isCompatibleTypeChange = ddl.isCompatibleTypeChange;
31
31
  });
32
32
 
33
33
  describe('buildIndexSQL (MySQL)', () => {
@@ -186,21 +186,45 @@ describe('generateDDLClause (MySQL)', () => {
186
186
  });
187
187
  });
188
188
 
189
- describe('isPgCompatibleTypeChange', () => {
189
+ describe('isCompatibleTypeChange', () => {
190
190
  test('varchar -> text 是兼容变更', () => {
191
- expect(isPgCompatibleTypeChange('character varying', 'text')).toBe(true);
191
+ expect(isCompatibleTypeChange('character varying', 'text')).toBe(true);
192
+ expect(isCompatibleTypeChange('varchar(100)', 'text')).toBe(true);
193
+ expect(isCompatibleTypeChange('varchar(100)', 'mediumtext')).toBe(true);
192
194
  });
193
195
 
194
196
  test('text -> varchar 不是兼容变更', () => {
195
- expect(isPgCompatibleTypeChange('text', 'character varying')).toBe(false);
197
+ expect(isCompatibleTypeChange('text', 'character varying')).toBe(false);
198
+ expect(isCompatibleTypeChange('text', 'varchar(100)')).toBe(false);
199
+ });
200
+
201
+ test('int -> bigint 是兼容变更', () => {
202
+ expect(isCompatibleTypeChange('int', 'bigint')).toBe(true);
203
+ expect(isCompatibleTypeChange('int unsigned', 'bigint unsigned')).toBe(true);
204
+ expect(isCompatibleTypeChange('tinyint', 'int')).toBe(true);
205
+ expect(isCompatibleTypeChange('tinyint', 'bigint')).toBe(true);
206
+ expect(isCompatibleTypeChange('smallint', 'int')).toBe(true);
207
+ expect(isCompatibleTypeChange('mediumint', 'bigint')).toBe(true);
208
+ });
209
+
210
+ test('bigint -> int 不是兼容变更(收缩)', () => {
211
+ expect(isCompatibleTypeChange('bigint', 'int')).toBe(false);
212
+ expect(isCompatibleTypeChange('int', 'tinyint')).toBe(false);
213
+ });
214
+
215
+ test('PG integer -> bigint 是兼容变更', () => {
216
+ expect(isCompatibleTypeChange('integer', 'bigint')).toBe(true);
217
+ expect(isCompatibleTypeChange('smallint', 'integer')).toBe(true);
218
+ expect(isCompatibleTypeChange('smallint', 'bigint')).toBe(true);
196
219
  });
197
220
 
198
221
  test('相同类型不是变更', () => {
199
- expect(isPgCompatibleTypeChange('text', 'text')).toBe(false);
222
+ expect(isCompatibleTypeChange('text', 'text')).toBe(false);
223
+ expect(isCompatibleTypeChange('bigint', 'bigint')).toBe(false);
200
224
  });
201
225
 
202
226
  test('空值处理', () => {
203
- expect(isPgCompatibleTypeChange(null, 'text')).toBe(false);
204
- expect(isPgCompatibleTypeChange('text', null)).toBe(false);
227
+ expect(isCompatibleTypeChange(null, 'text')).toBe(false);
228
+ expect(isCompatibleTypeChange('text', null)).toBe(false);
205
229
  });
206
230
  });