befly 3.9.40 → 3.10.0

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 (141) hide show
  1. package/README.md +39 -8
  2. package/befly.config.ts +19 -2
  3. package/checks/checkApi.ts +79 -77
  4. package/checks/checkHook.ts +48 -0
  5. package/checks/checkMenu.ts +168 -0
  6. package/checks/checkPlugin.ts +48 -0
  7. package/checks/checkTable.ts +137 -183
  8. package/docs/README.md +1 -1
  9. package/docs/api/api.md +1 -1
  10. package/docs/guide/quickstart.md +16 -9
  11. package/docs/hooks/hook.md +2 -2
  12. package/docs/hooks/rateLimit.md +1 -1
  13. package/docs/infra/redis.md +7 -7
  14. package/docs/plugins/plugin.md +23 -21
  15. package/docs/quickstart.md +16 -9
  16. package/docs/reference/addon.md +12 -1
  17. package/docs/reference/config.md +13 -30
  18. package/docs/reference/sync.md +62 -193
  19. package/docs/reference/table.md +27 -29
  20. package/hooks/auth.ts +3 -4
  21. package/hooks/cors.ts +4 -6
  22. package/hooks/parser.ts +3 -4
  23. package/hooks/permission.ts +3 -4
  24. package/hooks/validator.ts +3 -4
  25. package/lib/cacheHelper.ts +89 -153
  26. package/lib/cacheKeys.ts +1 -1
  27. package/lib/connect.ts +9 -13
  28. package/lib/dbDialect.ts +285 -0
  29. package/lib/dbHelper.ts +179 -507
  30. package/lib/dbUtils.ts +450 -0
  31. package/lib/logger.ts +41 -5
  32. package/lib/redisHelper.ts +1 -0
  33. package/lib/sqlBuilder.ts +358 -58
  34. package/lib/sqlCheck.ts +136 -0
  35. package/lib/validator.ts +1 -1
  36. package/loader/loadApis.ts +23 -126
  37. package/loader/loadHooks.ts +31 -46
  38. package/loader/loadPlugins.ts +37 -52
  39. package/main.ts +58 -19
  40. package/package.json +24 -25
  41. package/paths.ts +14 -14
  42. package/plugins/cache.ts +12 -6
  43. package/plugins/cipher.ts +2 -2
  44. package/plugins/config.ts +6 -8
  45. package/plugins/db.ts +14 -19
  46. package/plugins/jwt.ts +6 -7
  47. package/plugins/logger.ts +7 -9
  48. package/plugins/redis.ts +8 -10
  49. package/plugins/tool.ts +3 -4
  50. package/router/api.ts +3 -2
  51. package/router/static.ts +7 -5
  52. package/sync/syncApi.ts +80 -235
  53. package/sync/syncCache.ts +16 -0
  54. package/sync/syncDev.ts +167 -202
  55. package/sync/syncMenu.ts +230 -444
  56. package/sync/syncTable.ts +1247 -0
  57. package/tests/_mocks/mockSqliteDb.ts +204 -0
  58. package/tests/addonHelper-cache.test.ts +32 -0
  59. package/tests/apiHandler-routePath-only.test.ts +32 -0
  60. package/tests/cacheHelper.test.ts +16 -51
  61. package/tests/checkApi-routePath-strict.test.ts +166 -0
  62. package/tests/checkMenu.test.ts +346 -0
  63. package/tests/checkTable-smoke.test.ts +157 -0
  64. package/tests/dbDialect-cache.test.ts +23 -0
  65. package/tests/dbDialect.test.ts +46 -0
  66. package/tests/dbHelper-advanced.test.ts +1 -1
  67. package/tests/dbHelper-all-array-types.test.ts +15 -15
  68. package/tests/dbHelper-batch-write.test.ts +90 -0
  69. package/tests/dbHelper-columns.test.ts +36 -54
  70. package/tests/dbHelper-execute.test.ts +26 -26
  71. package/tests/dbHelper-joins.test.ts +85 -176
  72. package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
  73. package/tests/fixtures/scanFilesApis/a.ts +3 -0
  74. package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
  75. package/tests/loadPlugins-order-smoke.test.ts +75 -0
  76. package/tests/logger.test.ts +6 -6
  77. package/tests/redisHelper.test.ts +6 -1
  78. package/tests/scanFiles-routePath.test.ts +46 -0
  79. package/tests/smoke-sql.test.ts +24 -0
  80. package/tests/sqlBuilder-advanced.test.ts +18 -5
  81. package/tests/sqlBuilder.test.ts +24 -0
  82. package/tests/sync-init-guard.test.ts +105 -0
  83. package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
  84. package/tests/syncApi-obsolete-records.test.ts +69 -0
  85. package/tests/syncApi-type-compat.test.ts +72 -0
  86. package/tests/syncDev-permissions.test.ts +81 -0
  87. package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
  88. package/tests/syncMenu-duplicate-path.test.ts +122 -0
  89. package/tests/syncMenu-obsolete-records.test.ts +161 -0
  90. package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
  91. package/tests/syncMenu-paths.test.ts +0 -9
  92. package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
  93. package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
  94. package/tests/syncTable-constants.test.ts +101 -0
  95. package/tests/syncTable-db-integration.test.ts +237 -0
  96. package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
  97. package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
  98. package/tests/syncTable-schema.test.ts +99 -0
  99. package/tests/syncTable-testkit.test.ts +25 -0
  100. package/tests/syncTable-types.test.ts +122 -0
  101. package/tests/tableRef-and-deserialize.test.ts +67 -0
  102. package/tsconfig.json +1 -1
  103. package/types/api.d.ts +1 -1
  104. package/types/befly.d.ts +13 -12
  105. package/types/cache.d.ts +2 -2
  106. package/types/context.d.ts +1 -1
  107. package/types/database.d.ts +0 -5
  108. package/types/hook.d.ts +1 -10
  109. package/types/plugin.d.ts +2 -96
  110. package/types/sync.d.ts +19 -25
  111. package/utils/convertBigIntFields.ts +38 -0
  112. package/utils/disableMenusGlob.ts +85 -0
  113. package/utils/importDefault.ts +21 -0
  114. package/utils/isDirentDirectory.ts +23 -0
  115. package/utils/loadMenuConfigs.ts +145 -0
  116. package/utils/processFields.ts +25 -0
  117. package/utils/scanAddons.ts +72 -0
  118. package/utils/scanFiles.ts +129 -21
  119. package/utils/scanSources.ts +64 -0
  120. package/utils/sortModules.ts +137 -0
  121. package/checks/checkApp.ts +0 -55
  122. package/hooks/rateLimit.ts +0 -276
  123. package/sync/syncAll.ts +0 -35
  124. package/sync/syncDb/apply.ts +0 -192
  125. package/sync/syncDb/constants.ts +0 -119
  126. package/sync/syncDb/ddl.ts +0 -251
  127. package/sync/syncDb/helpers.ts +0 -84
  128. package/sync/syncDb/schema.ts +0 -202
  129. package/sync/syncDb/sqlite.ts +0 -48
  130. package/sync/syncDb/table.ts +0 -207
  131. package/sync/syncDb/tableCreate.ts +0 -163
  132. package/sync/syncDb/types.ts +0 -132
  133. package/sync/syncDb/version.ts +0 -69
  134. package/sync/syncDb.ts +0 -168
  135. package/tests/rateLimit-hook.test.ts +0 -477
  136. package/tests/syncDb-constants.test.ts +0 -130
  137. package/tests/syncDb-schema.test.ts +0 -179
  138. package/tests/syncDb-types.test.ts +0 -139
  139. package/utils/addonHelper.ts +0 -90
  140. package/utils/modules.ts +0 -98
  141. package/utils/route.ts +0 -23
@@ -0,0 +1,1247 @@
1
+ /**
2
+ * syncTable 命令 - 同步数据库表结构(单文件版)
3
+ *
4
+ * 说明:
5
+ * - 历史上该能力拆分在 packages/core/sync/syncTable/* 多个模块中
6
+ * - 现在按项目要求,将所有实现合并到本文件(目录 packages/core/sync/syncTable/ 已删除)
7
+ */
8
+
9
+ import type { DbDialectName } from "../lib/dbDialect.js";
10
+ import type { BeflyContext } from "../types/befly.js";
11
+ import type { ColumnInfo, FieldChange, IndexInfo, TablePlan } from "../types/sync.js";
12
+ import type { FieldDefinition } from "../types/validate.js";
13
+ import type { ScanFileResult } from "../utils/scanFiles.js";
14
+
15
+ import { snakeCase } from "es-toolkit/string";
16
+
17
+ import { CacheKeys } from "../lib/cacheKeys.js";
18
+ import { getDialectByName, getSyncTableColumnsInfoQuery, getSyncTableIndexesQuery } from "../lib/dbDialect.js";
19
+ import { Logger } from "../lib/logger.js";
20
+
21
+ type SqlExecutor = {
22
+ unsafe<T = any>(sqlStr: string, params?: unknown[]): Promise<T[]>;
23
+ };
24
+
25
+ type DbDialect = DbDialectName;
26
+
27
+ /* ========================================================================== */
28
+ /* 对外导出面
29
+ *
30
+ * 约束:本文件仅导出一个函数:syncTable。
31
+ * - 生产代码:通过 await syncTable(ctx, items) 执行同步。
32
+ * - 测试:通过 syncTable.TestKit 访问纯函数/常量(不再导出零散函数)。
33
+ */
34
+ /* ========================================================================== */
35
+
36
+ /**
37
+ * 文件导航(推荐阅读顺序)
38
+ * 1) syncTable(ctx, items) 入口(本段下方)
39
+ * 2) 版本/常量/方言判断(DB_VERSION_REQUIREMENTS 等)
40
+ * 3) 通用 DDL 工具(quote/type/default/ddl/index SQL)
41
+ * 4) Runtime I/O(只读元信息:表/列/索引/版本)
42
+ * 5) plan/apply(写变更:建表/改表/SQLite 重建)
43
+ */
44
+
45
+ type SyncTableFn = ((ctx: BeflyContext, items: ScanFileResult[]) => Promise<void>) & {
46
+ TestKit: typeof SYNC_TABLE_TEST_KIT;
47
+ };
48
+
49
+ /**
50
+ * 数据库同步命令入口(函数模式)
51
+ */
52
+ export const syncTable = (async (ctx: BeflyContext, items: ScanFileResult[]): Promise<void> => {
53
+ try {
54
+ // 记录处理过的表名(用于清理缓存)
55
+ const processedTables: string[] = [];
56
+
57
+ if (!Array.isArray(items)) {
58
+ throw new Error("syncTable(items) 参数必须是数组");
59
+ }
60
+
61
+ if (!ctx) {
62
+ throw new Error("syncTable(ctx, items) 缺少 ctx");
63
+ }
64
+ if (!ctx.db) {
65
+ throw new Error("syncTable(ctx, items) 缺少 ctx.db");
66
+ }
67
+ if (!ctx.redis) {
68
+ throw new Error("syncTable(ctx, items) 缺少 ctx.redis");
69
+ }
70
+ if (!ctx.config) {
71
+ throw new Error("syncTable(ctx, items) 缺少 ctx.config");
72
+ }
73
+
74
+ // DbDialect 归一化(允许值与映射关系):
75
+ //
76
+ // | ctx.config.db.type 输入 | 归一化 dbDialect |
77
+ // |------------------------|------------------|
78
+ // | mysql / 其他 / 空值 | mysql |
79
+ // | postgres / postgresql | postgresql |
80
+ // | sqlite | sqlite |
81
+ //
82
+ // 约束:后续若新增方言,必须同步更新:
83
+ // - 这里的归一化
84
+ // - ensureDbVersion / runtime I/O / DDL 分支
85
+ const dbType = String(ctx.config.db?.type || "mysql").toLowerCase();
86
+ let dbDialect: DbDialect = "mysql";
87
+ if (dbType === "postgres" || dbType === "postgresql") {
88
+ dbDialect = "postgresql";
89
+ } else if (dbType === "sqlite") {
90
+ dbDialect = "sqlite";
91
+ }
92
+
93
+ // 检查数据库版本(复用 ctx.db 的现有连接/事务)
94
+ await ensureDbVersion(dbDialect, ctx.db);
95
+
96
+ const databaseName = ctx.config.db?.database || "";
97
+ const runtime: SyncRuntime = {
98
+ dbDialect: dbDialect,
99
+ db: ctx.db,
100
+ dbName: databaseName
101
+ };
102
+
103
+ // 处理传入的 tables 数据(来自 scanSources)
104
+ for (const item of items) {
105
+ if (!item || item.type !== "table") {
106
+ continue;
107
+ }
108
+
109
+ if (item.source !== "app" && item.source !== "addon" && item.source !== "core") {
110
+ Logger.warn(`syncTable 跳过未知来源表定义: source=${String(item.source)} fileName=${String(item.fileName)}`);
111
+ continue;
112
+ }
113
+
114
+ // 确定表名:
115
+ // - addon 表:addon_{addonName}_{fileName}
116
+ // - app/core 表:{fileName}
117
+ const baseTableName = snakeCase(item.fileName);
118
+
119
+ let tableName = baseTableName;
120
+ if (item.source === "addon") {
121
+ if (!item.addonName || String(item.addonName).trim() === "") {
122
+ throw new Error(`syncTable addon 表缺少 addonName: fileName=${String(item.fileName)}`);
123
+ }
124
+ tableName = `addon_${snakeCase(item.addonName)}_${baseTableName}`;
125
+ }
126
+
127
+ const tableDefinition = item.content;
128
+ if (!tableDefinition || typeof tableDefinition !== "object") {
129
+ throw new Error(`syncTable 表定义无效: table=${tableName}`);
130
+ }
131
+
132
+ // 为字段属性设置默认值:表定义来自 JSON/扫描结果,字段可能缺省。
133
+ // 缺省会让 diff/DDL 生成出现 undefined vs null 等差异,导致错误的变更判断。
134
+ for (const fieldDef of Object.values(tableDefinition)) {
135
+ applyFieldDefaults(fieldDef);
136
+ }
137
+
138
+ const existsTable = await tableExistsRuntime(runtime, tableName);
139
+
140
+ if (existsTable) {
141
+ await modifyTableRuntime(runtime, tableName, tableDefinition as any);
142
+ } else {
143
+ await createTable(runtime, tableName, tableDefinition as any);
144
+ }
145
+
146
+ // 记录处理过的表名(用于清理缓存)
147
+ processedTables.push(tableName);
148
+ }
149
+
150
+ // 清理 Redis 缓存(如果有表被处理)
151
+ if (processedTables.length > 0) {
152
+ const cacheKeys = processedTables.map((tableName) => CacheKeys.tableColumns(tableName));
153
+ await ctx.redis.delBatch(cacheKeys);
154
+ }
155
+ } catch (error: any) {
156
+ Logger.error({ err: error }, "数据库同步失败");
157
+ throw error;
158
+ }
159
+ }) as SyncTableFn;
160
+
161
+ /* ========================================================================== */
162
+ /* 版本/常量/运行时方言状态 */
163
+ /* ========================================================================== */
164
+
165
+ /**
166
+ * 数据库版本要求
167
+ */
168
+ const DB_VERSION_REQUIREMENTS = {
169
+ MYSQL_MIN_MAJOR: 8,
170
+ POSTGRES_MIN_MAJOR: 17,
171
+ SQLITE_MIN_VERSION: "3.50.0",
172
+ SQLITE_MIN_VERSION_NUM: 35000 // 3 * 10000 + 50 * 100
173
+ } as const;
174
+
175
+ /**
176
+ * 字段变更类型的中文标签映射
177
+ */
178
+ const CHANGE_TYPE_LABELS = {
179
+ length: "长度",
180
+ datatype: "类型",
181
+ comment: "注释",
182
+ default: "默认值",
183
+ nullable: "可空约束",
184
+ unique: "唯一约束"
185
+ } as const;
186
+
187
+ /**
188
+ * MySQL 表配置
189
+ */
190
+ const MYSQL_TABLE_CONFIG = {
191
+ ENGINE: "InnoDB",
192
+ CHARSET: "utf8mb4",
193
+ COLLATE: "utf8mb4_0900_ai_ci"
194
+ } as const;
195
+
196
+ type SystemFieldMeta = {
197
+ name: "id" | "created_at" | "updated_at" | "deleted_at" | "state";
198
+ comment: string;
199
+ needsIndex: boolean;
200
+ mysqlDdl: string;
201
+ pgDdl: string;
202
+ sqliteDdl: string;
203
+ };
204
+
205
+ /**
206
+ * 系统字段定义:三处会用到
207
+ * - createTable:建表时追加系统字段列定义
208
+ * - modifyTable:对已存在的表补齐缺失的系统字段
209
+ * - SYSTEM_INDEX_FIELDS:从 needsIndex 派生默认系统索引集合
210
+ */
211
+ const SYSTEM_FIELDS: ReadonlyArray<SystemFieldMeta> = [
212
+ {
213
+ name: "id",
214
+ comment: "主键ID",
215
+ needsIndex: false,
216
+ mysqlDdl: "BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT",
217
+ pgDdl: "BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY",
218
+ sqliteDdl: "INTEGER PRIMARY KEY"
219
+ },
220
+ {
221
+ name: "created_at",
222
+ comment: "创建时间",
223
+ needsIndex: true,
224
+ mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 0",
225
+ pgDdl: "BIGINT NOT NULL DEFAULT 0",
226
+ sqliteDdl: "INTEGER NOT NULL DEFAULT 0"
227
+ },
228
+ {
229
+ name: "updated_at",
230
+ comment: "更新时间",
231
+ needsIndex: true,
232
+ mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 0",
233
+ pgDdl: "BIGINT NOT NULL DEFAULT 0",
234
+ sqliteDdl: "INTEGER NOT NULL DEFAULT 0"
235
+ },
236
+ {
237
+ name: "deleted_at",
238
+ comment: "删除时间",
239
+ needsIndex: false,
240
+ mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 0",
241
+ pgDdl: "BIGINT NOT NULL DEFAULT 0",
242
+ sqliteDdl: "INTEGER NOT NULL DEFAULT 0"
243
+ },
244
+ {
245
+ name: "state",
246
+ comment: "状态字段",
247
+ needsIndex: true,
248
+ mysqlDdl: "BIGINT UNSIGNED NOT NULL DEFAULT 1",
249
+ pgDdl: "BIGINT NOT NULL DEFAULT 1",
250
+ sqliteDdl: "INTEGER NOT NULL DEFAULT 1"
251
+ }
252
+ ];
253
+
254
+ /**
255
+ * 需要创建索引的系统字段
256
+ */
257
+ const SYSTEM_INDEX_FIELDS: ReadonlyArray<string> = SYSTEM_FIELDS.filter((f) => f.needsIndex).map((f) => f.name);
258
+
259
+ const SYSTEM_FIELD_META_MAP: Record<string, SystemFieldMeta> = {};
260
+ for (const f of SYSTEM_FIELDS) {
261
+ SYSTEM_FIELD_META_MAP[f.name] = f;
262
+ }
263
+
264
+ const SYNC_TABLE_TEST_KIT = {
265
+ DB_VERSION_REQUIREMENTS: DB_VERSION_REQUIREMENTS,
266
+ CHANGE_TYPE_LABELS: CHANGE_TYPE_LABELS,
267
+ MYSQL_TABLE_CONFIG: MYSQL_TABLE_CONFIG,
268
+ SYSTEM_INDEX_FIELDS: SYSTEM_INDEX_FIELDS,
269
+
270
+ getTypeMapping: getTypeMapping,
271
+ quoteIdentifier: quoteIdentifier,
272
+ escapeComment: escapeComment,
273
+ applyFieldDefaults: applyFieldDefaults,
274
+ isStringOrArrayType: isStringOrArrayType,
275
+ getSqlType: getSqlType,
276
+ resolveDefaultValue: resolveDefaultValue,
277
+ generateDefaultSql: generateDefaultSql,
278
+ buildIndexSQL: buildIndexSQL,
279
+ buildSystemColumnDefs: buildSystemColumnDefs,
280
+ buildBusinessColumnDefs: buildBusinessColumnDefs,
281
+ generateDDLClause: generateDDLClause,
282
+ isCompatibleTypeChange: isCompatibleTypeChange,
283
+ compareFieldDefinition: compareFieldDefinition,
284
+
285
+ tableExistsRuntime: tableExistsRuntime,
286
+ getTableColumnsRuntime: getTableColumnsRuntime,
287
+ getTableIndexesRuntime: getTableIndexesRuntime,
288
+
289
+ createRuntime: (dbDialect: DbDialect, db: SqlExecutor, dbName: string = ""): SyncRuntime => {
290
+ return {
291
+ dbDialect: dbDialect,
292
+ db: db,
293
+ dbName: dbName
294
+ };
295
+ }
296
+ };
297
+
298
+ // 测试能力挂载(避免导出零散函数,同时确保运行时存在)
299
+ syncTable.TestKit = SYNC_TABLE_TEST_KIT;
300
+
301
+ // 防御性:避免运行时被误覆盖(只读),但仍保持可枚举/可访问。
302
+ Object.defineProperty(syncTable, "TestKit", {
303
+ value: SYNC_TABLE_TEST_KIT,
304
+ writable: false,
305
+ enumerable: true,
306
+ configurable: false
307
+ });
308
+
309
+ /**
310
+ * 获取字段类型映射(根据当前数据库类型)
311
+ */
312
+ function getTypeMapping(dbDialect: DbDialect): Record<string, string> {
313
+ return {
314
+ number: dbDialect === "sqlite" ? "INTEGER" : dbDialect === "postgresql" ? "BIGINT" : "BIGINT",
315
+ string: dbDialect === "sqlite" ? "TEXT" : dbDialect === "postgresql" ? "character varying" : "VARCHAR",
316
+ text: dbDialect === "mysql" ? "MEDIUMTEXT" : "TEXT",
317
+ array_string: dbDialect === "sqlite" ? "TEXT" : dbDialect === "postgresql" ? "character varying" : "VARCHAR",
318
+ array_text: dbDialect === "mysql" ? "MEDIUMTEXT" : "TEXT",
319
+ array_number_string: dbDialect === "sqlite" ? "TEXT" : dbDialect === "postgresql" ? "character varying" : "VARCHAR",
320
+ array_number_text: dbDialect === "mysql" ? "MEDIUMTEXT" : "TEXT"
321
+ };
322
+ }
323
+
324
+ /* ========================================================================== */
325
+ /* 通用工具与 DDL 片段生成 */
326
+ /* ========================================================================== */
327
+
328
+ /**
329
+ * 根据数据库类型引用标识符
330
+ */
331
+ function quoteIdentifier(dbDialect: DbDialect, identifier: string): string {
332
+ return getDialectByName(dbDialect).quoteIdent(identifier);
333
+ }
334
+
335
+ /**
336
+ * 转义 SQL 注释中的双引号
337
+ */
338
+ function escapeComment(str: string): string {
339
+ return String(str).replace(/"/g, '\\"');
340
+ }
341
+
342
+ // 注意:这里刻意不封装“logFieldChange/formatFieldList”之类的一次性工具函数,
343
+ // 以减少抽象层级(按项目要求:能直写就直写)。
344
+
345
+ /**
346
+ * 为字段定义应用默认值
347
+ */
348
+ function applyFieldDefaults(fieldDef: any): void {
349
+ fieldDef.detail = fieldDef.detail ?? "";
350
+ fieldDef.min = fieldDef.min ?? 0;
351
+ fieldDef.max = fieldDef.max ?? (fieldDef.type === "number" ? Number.MAX_SAFE_INTEGER : 100);
352
+ fieldDef.default = fieldDef.default ?? null;
353
+ fieldDef.index = fieldDef.index ?? false;
354
+ fieldDef.unique = fieldDef.unique ?? false;
355
+ fieldDef.nullable = fieldDef.nullable ?? false;
356
+ fieldDef.unsigned = fieldDef.unsigned ?? true;
357
+ fieldDef.regexp = fieldDef.regexp ?? null;
358
+ }
359
+
360
+ /**
361
+ * 判断是否为字符串或数组类型(需要长度参数)
362
+ */
363
+ function isStringOrArrayType(fieldType: string): boolean {
364
+ return fieldType === "string" || fieldType === "array_string" || fieldType === "array_number_string";
365
+ }
366
+
367
+ /**
368
+ * 获取 SQL 数据类型
369
+ */
370
+ function getSqlType(dbDialect: DbDialect, fieldType: string, fieldMax: number | null, unsigned: boolean = false): string {
371
+ const typeMapping = getTypeMapping(dbDialect);
372
+ if (isStringOrArrayType(fieldType)) {
373
+ return `${typeMapping[fieldType]}(${fieldMax})`;
374
+ }
375
+ const baseType = typeMapping[fieldType] || "TEXT";
376
+ if (dbDialect === "mysql" && fieldType === "number" && unsigned) {
377
+ return `${baseType} UNSIGNED`;
378
+ }
379
+ return baseType;
380
+ }
381
+
382
+ /**
383
+ * 处理默认值:将 null 或 'null' 字符串转换为对应类型的默认值
384
+ */
385
+ function resolveDefaultValue(fieldDefault: any, fieldType: string): any {
386
+ if (fieldDefault !== null && fieldDefault !== "null") {
387
+ return fieldDefault;
388
+ }
389
+
390
+ switch (fieldType) {
391
+ case "number":
392
+ return 0;
393
+ case "string":
394
+ return "";
395
+ case "array_string":
396
+ case "array_number_string":
397
+ return "[]";
398
+ case "text":
399
+ case "array_text":
400
+ case "array_number_text":
401
+ return "null";
402
+ default:
403
+ return fieldDefault;
404
+ }
405
+ }
406
+
407
+ /**
408
+ * 生成 SQL DEFAULT 子句
409
+ */
410
+ function generateDefaultSql(actualDefault: any, fieldType: string): string {
411
+ if (fieldType === "text" || fieldType === "array_text" || actualDefault === "null") {
412
+ return "";
413
+ }
414
+
415
+ if (fieldType === "number" || fieldType === "string" || fieldType === "array_string" || fieldType === "array_number_string") {
416
+ if (typeof actualDefault === "number" && !Number.isNaN(actualDefault)) {
417
+ return ` DEFAULT ${actualDefault}`;
418
+ } else {
419
+ const escaped = String(actualDefault).replace(/'/g, "''");
420
+ return ` DEFAULT '${escaped}'`;
421
+ }
422
+ }
423
+
424
+ return "";
425
+ }
426
+
427
+ /**
428
+ * 构建索引操作 SQL(统一使用在线策略)
429
+ */
430
+ function buildIndexSQL(dbDialect: DbDialect, tableName: string, indexName: string, fieldName: string, action: "create" | "drop"): string {
431
+ // 说明(策略取舍):
432
+ // - MySQL:通过 ALTER TABLE 在线添加/删除索引;配合 ALGORITHM/LOCK 以降低阻塞。
433
+ // - PostgreSQL:CREATE/DROP INDEX CONCURRENTLY 尽量减少锁表(代价是执行更慢/有并发限制)。
434
+ // - SQLite:DDL 能力有限,使用 IF NOT EXISTS/IF EXISTS 尽量做到幂等。
435
+ const tableQuoted = quoteIdentifier(dbDialect, tableName);
436
+ const indexQuoted = quoteIdentifier(dbDialect, indexName);
437
+ const fieldQuoted = quoteIdentifier(dbDialect, fieldName);
438
+
439
+ if (dbDialect === "mysql") {
440
+ const parts: string[] = [];
441
+ if (action === "create") {
442
+ parts.push(`ADD INDEX ${indexQuoted} (${fieldQuoted})`);
443
+ } else {
444
+ parts.push(`DROP INDEX ${indexQuoted}`);
445
+ }
446
+ return `ALTER TABLE ${tableQuoted} ALGORITHM=INPLACE, LOCK=NONE, ${parts.join(", ")}`;
447
+ }
448
+
449
+ if (dbDialect === "postgresql") {
450
+ if (action === "create") {
451
+ return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${indexQuoted} ON ${tableQuoted}(${fieldQuoted})`;
452
+ }
453
+ return `DROP INDEX CONCURRENTLY IF EXISTS ${indexQuoted}`;
454
+ }
455
+
456
+ if (action === "create") {
457
+ return `CREATE INDEX IF NOT EXISTS ${indexQuoted} ON ${tableQuoted}(${fieldQuoted})`;
458
+ }
459
+ return `DROP INDEX IF EXISTS ${indexQuoted}`;
460
+ }
461
+
462
+ /**
463
+ * 获取单个系统字段的列定义(用于 ADD COLUMN 或 CREATE TABLE)
464
+ */
465
+ function getSystemColumnDef(dbDialect: DbDialect, fieldName: string): string | null {
466
+ const meta = SYSTEM_FIELD_META_MAP[fieldName];
467
+ if (!meta) return null;
468
+
469
+ const colQuoted = quoteIdentifier(dbDialect, meta.name);
470
+ if (dbDialect === "mysql") {
471
+ return `${colQuoted} ${meta.mysqlDdl} COMMENT "${escapeComment(meta.comment)}"`;
472
+ }
473
+
474
+ if (dbDialect === "postgresql") {
475
+ return `${colQuoted} ${meta.pgDdl}`;
476
+ }
477
+
478
+ return `${colQuoted} ${meta.sqliteDdl}`;
479
+ }
480
+
481
+ /**
482
+ * 构建系统字段列定义
483
+ */
484
+ function buildSystemColumnDefs(dbDialect: DbDialect): string[] {
485
+ const defs: string[] = [];
486
+ for (const f of SYSTEM_FIELDS) {
487
+ const d = getSystemColumnDef(dbDialect, f.name);
488
+ if (d) defs.push(d);
489
+ }
490
+ return defs;
491
+ }
492
+
493
+ /**
494
+ * 构建业务字段列定义
495
+ */
496
+ function buildBusinessColumnDefs(dbDialect: DbDialect, fields: Record<string, FieldDefinition>): string[] {
497
+ const colDefs: string[] = [];
498
+
499
+ for (const [fieldKey, fieldDef] of Object.entries(fields)) {
500
+ const dbFieldName = snakeCase(fieldKey);
501
+ const colQuoted = quoteIdentifier(dbDialect, dbFieldName);
502
+
503
+ const sqlType = getSqlType(dbDialect, fieldDef.type, fieldDef.max, fieldDef.unsigned);
504
+
505
+ const actualDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
506
+ const defaultSql = generateDefaultSql(actualDefault, fieldDef.type);
507
+
508
+ const uniqueSql = fieldDef.unique ? " UNIQUE" : "";
509
+ const nullableSql = fieldDef.nullable ? " NULL" : " NOT NULL";
510
+
511
+ if (dbDialect === "mysql") {
512
+ colDefs.push(`${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql} COMMENT "${escapeComment(fieldDef.name)}"`);
513
+ } else {
514
+ colDefs.push(`${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`);
515
+ }
516
+ }
517
+
518
+ return colDefs;
519
+ }
520
+
521
+ /**
522
+ * 生成字段 DDL 子句(不含 ALTER TABLE 前缀)
523
+ */
524
+ function generateDDLClause(dbDialect: DbDialect, fieldKey: string, fieldDef: FieldDefinition, isAdd: boolean = false): string {
525
+ // 说明(策略取舍):
526
+ // - MySQL:ADD/MODIFY 一条子句内可同时表达类型/可空/默认值/注释(同步成本低)。
527
+ // - PostgreSQL:modify 场景这里仅生成 TYPE 变更;默认值/注释等由其他子句或 commentActions 处理。
528
+ // - SQLite:不支持标准化的 MODIFY COLUMN,这里仅提供 ADD COLUMN;复杂变更通过 rebuildSqliteTable 完成。
529
+ const dbFieldName = snakeCase(fieldKey);
530
+ const colQuoted = quoteIdentifier(dbDialect, dbFieldName);
531
+
532
+ const sqlType = getSqlType(dbDialect, fieldDef.type, fieldDef.max, fieldDef.unsigned);
533
+
534
+ const actualDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
535
+ const defaultSql = generateDefaultSql(actualDefault, fieldDef.type);
536
+
537
+ const uniqueSql = fieldDef.unique ? " UNIQUE" : "";
538
+ const nullableSql = fieldDef.nullable ? " NULL" : " NOT NULL";
539
+
540
+ if (dbDialect === "mysql") {
541
+ return `${isAdd ? "ADD COLUMN" : "MODIFY COLUMN"} ${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql} COMMENT "${escapeComment(fieldDef.name)}"`;
542
+ }
543
+ if (dbDialect === "postgresql") {
544
+ if (isAdd) return `ADD COLUMN IF NOT EXISTS ${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`;
545
+ return `ALTER COLUMN ${colQuoted} TYPE ${sqlType}`;
546
+ }
547
+ if (isAdd) return `ADD COLUMN IF NOT EXISTS ${colQuoted} ${sqlType}${uniqueSql}${nullableSql}${defaultSql}`;
548
+ return "";
549
+ }
550
+
551
+ /**
552
+ * 安全执行 DDL 语句(MySQL 降级策略)
553
+ */
554
+ async function executeDDLSafely(db: SqlExecutor, stmt: string): Promise<boolean> {
555
+ // MySQL DDL 兼容性/可用性兜底:
556
+ // - 优先执行原语句(通常含 ALGORITHM=INSTANT)。
557
+ // - 若 INSTANT 不可用(版本/表结构限制),降级为 INPLACE 再试。
558
+ // - 若仍失败,去掉 ALGORITHM/LOCK 提示字段,以最大兼容性执行传统 ALTER。
559
+ try {
560
+ await db.unsafe(stmt);
561
+ return true;
562
+ } catch (error: any) {
563
+ if (stmt.includes("ALGORITHM=INSTANT")) {
564
+ const inplaceSql = stmt.replace(/ALGORITHM=INSTANT/g, "ALGORITHM=INPLACE");
565
+ try {
566
+ await db.unsafe(inplaceSql);
567
+ return true;
568
+ } catch {
569
+ let traditionSql = stmt;
570
+ traditionSql = traditionSql.replace(/\bALGORITHM\s*=\s*(INPLACE|INSTANT)\b\s*,?\s*/g, "").replace(/\bLOCK\s*=\s*(NONE|SHARED|EXCLUSIVE)\b\s*,?\s*/g, "");
571
+
572
+ traditionSql = traditionSql
573
+ .replace(/,\s*,/g, ", ")
574
+ .replace(/,\s*$/g, "")
575
+ .replace(/\s{2,}/g, " ")
576
+ .trim();
577
+ await db.unsafe(traditionSql);
578
+ return true;
579
+ }
580
+ } else {
581
+ throw error;
582
+ }
583
+ }
584
+ }
585
+
586
+ /**
587
+ * 判断是否为兼容的类型变更(宽化型变更,无数据丢失风险)
588
+ */
589
+ function isCompatibleTypeChange(currentType: string, newType: string): boolean {
590
+ // 说明:该函数用于“自动同步”里的安全阈值判断。
591
+ // - 允许:宽化型变更(不收缩、不改变语义大类),例如:
592
+ // - INT -> BIGINT(或 tinyint/smallint/mediumint -> 更宽的整型)
593
+ // - VARCHAR -> TEXT/MEDIUMTEXT/LONGTEXT
594
+ // - character varying -> text(PG 常见)
595
+ // - 禁止:收缩型变更(BIGINT -> INT、TEXT -> VARCHAR)以及跨大类变更(需人工评估/迁移)。
596
+ const c = String(currentType || "").toLowerCase();
597
+ const n = String(newType || "").toLowerCase();
598
+
599
+ if (c === n) return false;
600
+
601
+ const cBase = c
602
+ .replace(/\s*unsigned/gi, "")
603
+ .replace(/\([^)]*\)/g, "")
604
+ .trim();
605
+
606
+ const nBase = n
607
+ .replace(/\s*unsigned/gi, "")
608
+ .replace(/\([^)]*\)/g, "")
609
+ .trim();
610
+
611
+ const intTypes = ["tinyint", "smallint", "mediumint", "int", "integer", "bigint"];
612
+ const cIntIdx = intTypes.indexOf(cBase);
613
+ const nIntIdx = intTypes.indexOf(nBase);
614
+ if (cIntIdx !== -1 && nIntIdx !== -1 && nIntIdx > cIntIdx) {
615
+ return true;
616
+ }
617
+
618
+ if (cBase === "varchar" && (nBase === "text" || nBase === "mediumtext" || nBase === "longtext")) return true;
619
+ if (cBase === "character varying" && nBase === "text") return true;
620
+
621
+ return false;
622
+ }
623
+
624
+ type SyncRuntime = {
625
+ /**
626
+ * 当前数据库方言(mysql/postgresql/sqlite),决定 SQL 片段与元信息查询方式。
627
+ * 约束:必须与 ctx.config.db.type 一致(经归一化)。
628
+ */
629
+ dbDialect: DbDialect;
630
+ /**
631
+ * SQL 执行器:必须复用 ctx.db。
632
+ * 约束:syncTable 内部禁止新建 DB 连接/事务;runtime 仅保存引用,不拥有生命周期。
633
+ */
634
+ db: SqlExecutor;
635
+ /**
636
+ * 数据库名:主要用于 MySQL information_schema 查询。
637
+ * 约束:PG/SQLite 可以传空字符串;不要在非 MySQL 方言依赖该值。
638
+ */
639
+ dbName: string;
640
+ };
641
+
642
+ /* ========================================================================== */
643
+ /* runtime I/O(只读:读库/元信息查询)
644
+ *
645
+ * 说明:
646
+ * - 本区块只负责“查询元信息”(表/列/索引/版本)。
647
+ * - 写变更(DDL 执行)统一在下方 plan/apply 区块中完成。
648
+ * - 对外不再保留 dbDialect/db/dbName 形式的 wrapper;统一使用 runtime 形态(更直写)。
649
+ */
650
+ /* ========================================================================== */
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // 读:表是否存在
654
+ // ---------------------------------------------------------------------------
655
+
656
+ async function tableExistsRuntime(runtime: SyncRuntime, tableName: string): Promise<boolean> {
657
+ if (!runtime.db) throw new Error("SQL 执行器未初始化");
658
+ try {
659
+ // 统一交由方言层构造 SQL;syncTable 仅决定“要查哪个 schema/db”。
660
+ // - MySQL:传 runtime.dbName(information_schema.table_schema)
661
+ // - PostgreSQL:固定 public(项目约定)
662
+ // - SQLite:忽略 schema
663
+ let schema: string | undefined = undefined;
664
+ if (runtime.dbDialect === "mysql") {
665
+ schema = runtime.dbName;
666
+ } else if (runtime.dbDialect === "postgresql") {
667
+ schema = "public";
668
+ }
669
+
670
+ const q = getDialectByName(runtime.dbDialect).tableExistsQuery(tableName, schema);
671
+ const res = await runtime.db.unsafe(q.sql, q.params);
672
+ return (res[0]?.count || 0) > 0;
673
+ } catch (error: any) {
674
+ const errMsg = String(error?.message || error);
675
+ throw new Error(`runtime I/O 失败: op=tableExists table=${tableName} err=${errMsg}`);
676
+ }
677
+ }
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // 读:列信息
681
+ // ---------------------------------------------------------------------------
682
+
683
+ async function getTableColumnsRuntime(runtime: SyncRuntime, tableName: string): Promise<{ [key: string]: ColumnInfo }> {
684
+ const columns: { [key: string]: ColumnInfo } = {};
685
+
686
+ try {
687
+ // 方言差异说明:
688
+ // - MySQL:information_schema.columns 最完整,包含 COLUMN_TYPE 与 COLUMN_COMMENT。
689
+ // - PostgreSQL:information_schema.columns 给基础列信息;注释需额外从 pg_class/pg_attribute 获取。
690
+ // - SQLite:PRAGMA table_info 仅提供 type/notnull/default 等有限信息,无列注释。
691
+ if (runtime.dbDialect === "mysql") {
692
+ const q = getSyncTableColumnsInfoQuery({ dialect: "mysql", table: tableName, dbName: runtime.dbName });
693
+ const result = await runtime.db.unsafe(q.columns.sql, q.columns.params);
694
+ for (const row of result) {
695
+ const defaultValue = row.COLUMN_DEFAULT;
696
+
697
+ columns[row.COLUMN_NAME] = {
698
+ type: row.DATA_TYPE,
699
+ columnType: row.COLUMN_TYPE,
700
+ length: row.CHARACTER_MAXIMUM_LENGTH,
701
+ max: row.CHARACTER_MAXIMUM_LENGTH,
702
+ nullable: row.IS_NULLABLE === "YES",
703
+ defaultValue: defaultValue,
704
+ comment: row.COLUMN_COMMENT
705
+ };
706
+ }
707
+ } else if (runtime.dbDialect === "postgresql") {
708
+ const q = getSyncTableColumnsInfoQuery({ dialect: "postgresql", table: tableName, dbName: runtime.dbName });
709
+ const result = await runtime.db.unsafe(q.columns.sql, q.columns.params);
710
+ const comments = q.comments ? await runtime.db.unsafe(q.comments.sql, q.comments.params) : [];
711
+ const commentMap: { [key: string]: string } = {};
712
+ for (const r of comments) commentMap[r.column_name] = r.column_comment;
713
+
714
+ for (const row of result) {
715
+ columns[row.column_name] = {
716
+ type: row.data_type,
717
+ columnType: row.data_type,
718
+ length: row.character_maximum_length,
719
+ max: row.character_maximum_length,
720
+ nullable: String(row.is_nullable).toUpperCase() === "YES",
721
+ defaultValue: row.column_default,
722
+ comment: commentMap[row.column_name] ?? null
723
+ };
724
+ }
725
+ } else if (runtime.dbDialect === "sqlite") {
726
+ const q = getSyncTableColumnsInfoQuery({ dialect: "sqlite", table: tableName, dbName: runtime.dbName });
727
+ const result = await runtime.db.unsafe(q.columns.sql, q.columns.params);
728
+ for (const row of result) {
729
+ let baseType = String(row.type || "").toUpperCase();
730
+ let max = null;
731
+ const m = /^(\w+)\s*\((\d+)\)/.exec(baseType);
732
+ if (m) {
733
+ baseType = m[1];
734
+ max = Number(m[2]);
735
+ }
736
+ columns[row.name] = {
737
+ type: baseType.toLowerCase(),
738
+ columnType: baseType.toLowerCase(),
739
+ length: max,
740
+ max: max,
741
+ nullable: row.notnull === 0,
742
+ defaultValue: row.dflt_value,
743
+ comment: null
744
+ };
745
+ }
746
+ }
747
+
748
+ return columns;
749
+ } catch (error: any) {
750
+ const errMsg = String(error?.message || error);
751
+ throw new Error(`runtime I/O 失败: op=getTableColumns table=${tableName} err=${errMsg}`);
752
+ }
753
+ }
754
+
755
+ // ---------------------------------------------------------------------------
756
+ // 读:索引信息(单列索引)
757
+ // ---------------------------------------------------------------------------
758
+
759
+ async function getTableIndexesRuntime(runtime: SyncRuntime, tableName: string): Promise<IndexInfo> {
760
+ const indexes: IndexInfo = {};
761
+
762
+ try {
763
+ // 方言差异说明:
764
+ // - MySQL:information_schema.statistics 直接给出 index -> column 映射。
765
+ // - PostgreSQL:pg_indexes 只有 indexdef,需要从定义里解析列名(这里仅取单列索引)。
766
+ // - SQLite:PRAGMA index_list + index_info;同样仅收集单列索引,避免多列索引误判。
767
+ if (runtime.dbDialect === "mysql") {
768
+ const q = getSyncTableIndexesQuery({ dialect: "mysql", table: tableName, dbName: runtime.dbName });
769
+ const result = await runtime.db.unsafe(q.sql, q.params);
770
+ for (const row of result) {
771
+ if (!indexes[row.INDEX_NAME]) indexes[row.INDEX_NAME] = [];
772
+ indexes[row.INDEX_NAME].push(row.COLUMN_NAME);
773
+ }
774
+ } else if (runtime.dbDialect === "postgresql") {
775
+ const q = getSyncTableIndexesQuery({ dialect: "postgresql", table: tableName, dbName: runtime.dbName });
776
+ const result = await runtime.db.unsafe(q.sql, q.params);
777
+ for (const row of result) {
778
+ const m = /\(([^)]+)\)/.exec(row.indexdef);
779
+ if (m) {
780
+ const col = m[1].replace(/"/g, "").trim();
781
+ indexes[row.indexname] = [col];
782
+ }
783
+ }
784
+ } else if (runtime.dbDialect === "sqlite") {
785
+ const quotedTable = quoteIdentifier("sqlite", tableName);
786
+ const list = await runtime.db.unsafe(`PRAGMA index_list(${quotedTable})`);
787
+ for (const idx of list) {
788
+ const quotedIndex = quoteIdentifier("sqlite", idx.name);
789
+ const info = await runtime.db.unsafe(`PRAGMA index_info(${quotedIndex})`);
790
+ const cols = info.map((r: any) => r.name);
791
+ if (cols.length === 1) indexes[idx.name] = cols;
792
+ }
793
+ }
794
+
795
+ return indexes;
796
+ } catch (error: any) {
797
+ const errMsg = String(error?.message || error);
798
+ throw new Error(`runtime I/O 失败: op=getTableIndexes table=${tableName} err=${errMsg}`);
799
+ }
800
+ }
801
+
802
+ // ---------------------------------------------------------------------------
803
+ // 读:数据库版本
804
+ // ---------------------------------------------------------------------------
805
+
806
+ /**
807
+ * 数据库版本检查(按方言)
808
+ */
809
+ async function ensureDbVersion(dbDialect: DbDialect, db: SqlExecutor): Promise<void> {
810
+ if (!db) throw new Error("SQL 执行器未初始化");
811
+
812
+ if (dbDialect === "mysql") {
813
+ const r = await db.unsafe("SELECT VERSION() AS version");
814
+ if (!r || r.length === 0 || !r[0]?.version) {
815
+ throw new Error("无法获取 MySQL 版本信息");
816
+ }
817
+ const version = r[0].version;
818
+ const majorVersion = parseInt(String(version).split(".")[0], 10);
819
+ if (!Number.isFinite(majorVersion) || majorVersion < DB_VERSION_REQUIREMENTS.MYSQL_MIN_MAJOR) {
820
+ throw new Error(`此脚本仅支持 MySQL ${DB_VERSION_REQUIREMENTS.MYSQL_MIN_MAJOR}.0+,当前版本: ${version}`);
821
+ }
822
+ return;
823
+ }
824
+
825
+ if (dbDialect === "postgresql") {
826
+ const r = await db.unsafe("SELECT version() AS version");
827
+ if (!r || r.length === 0 || !r[0]?.version) {
828
+ throw new Error("无法获取 PostgreSQL 版本信息");
829
+ }
830
+ const versionText = r[0].version;
831
+ const m = /PostgreSQL\s+(\d+)/i.exec(versionText);
832
+ const major = m ? parseInt(m[1], 10) : NaN;
833
+ if (!Number.isFinite(major) || major < DB_VERSION_REQUIREMENTS.POSTGRES_MIN_MAJOR) {
834
+ throw new Error(`此脚本要求 PostgreSQL >= ${DB_VERSION_REQUIREMENTS.POSTGRES_MIN_MAJOR},当前: ${versionText}`);
835
+ }
836
+ return;
837
+ }
838
+
839
+ if (dbDialect === "sqlite") {
840
+ const r = await db.unsafe("SELECT sqlite_version() AS version");
841
+ if (!r || r.length === 0 || !r[0]?.version) {
842
+ throw new Error("无法获取 SQLite 版本信息");
843
+ }
844
+ const version = r[0].version;
845
+ const [maj, min, patch] = String(version)
846
+ .split(".")
847
+ .map((v) => parseInt(v, 10) || 0);
848
+ const vnum = maj * 10000 + min * 100 + patch;
849
+ if (!Number.isFinite(vnum) || vnum < DB_VERSION_REQUIREMENTS.SQLITE_MIN_VERSION_NUM) {
850
+ throw new Error(`此脚本要求 SQLite >= ${DB_VERSION_REQUIREMENTS.SQLITE_MIN_VERSION},当前: ${version}`);
851
+ }
852
+ return;
853
+ }
854
+ }
855
+
856
+ /**
857
+ * 比较字段定义变化
858
+ */
859
+ function compareFieldDefinition(dbDialect: DbDialect, existingColumn: ColumnInfo, fieldDef: FieldDefinition): FieldChange[] {
860
+ const changes: FieldChange[] = [];
861
+
862
+ // SQLite 元信息能力较弱:
863
+ // - 列注释:sqlite 无 information_schema 注释,PRAGMA table_info 也不提供 comment
864
+ // - 字符串长度:sqlite 类型系统宽松,长度/类型信息不稳定(易产生误报)
865
+ // 因此在 sqlite 下跳过 comment/length 的 diff,仅保留更可靠的对比项。
866
+ if (dbDialect !== "sqlite" && isStringOrArrayType(fieldDef.type)) {
867
+ if (existingColumn.max !== fieldDef.max) {
868
+ changes.push({
869
+ type: "length",
870
+ current: existingColumn.max,
871
+ expected: fieldDef.max
872
+ });
873
+ }
874
+ }
875
+
876
+ if (dbDialect !== "sqlite") {
877
+ const currentComment = existingColumn.comment || "";
878
+ if (currentComment !== fieldDef.name) {
879
+ changes.push({
880
+ type: "comment",
881
+ current: currentComment,
882
+ expected: fieldDef.name
883
+ });
884
+ }
885
+ }
886
+
887
+ const typeMapping = getTypeMapping(dbDialect);
888
+ const expectedType = typeMapping[fieldDef.type].toLowerCase();
889
+ const currentType = existingColumn.type.toLowerCase();
890
+
891
+ if (currentType !== expectedType) {
892
+ changes.push({
893
+ type: "datatype",
894
+ current: currentType,
895
+ expected: expectedType
896
+ });
897
+ }
898
+
899
+ const expectedNullable = fieldDef.nullable;
900
+ if (existingColumn.nullable !== expectedNullable) {
901
+ changes.push({
902
+ type: "nullable",
903
+ current: existingColumn.nullable,
904
+ expected: expectedNullable
905
+ });
906
+ }
907
+
908
+ const expectedDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
909
+ if (String(existingColumn.defaultValue) !== String(expectedDefault)) {
910
+ changes.push({
911
+ type: "default",
912
+ current: existingColumn.defaultValue,
913
+ expected: expectedDefault
914
+ });
915
+ }
916
+
917
+ return changes;
918
+ }
919
+
920
+ /* ========================================================================== */
921
+ /* plan/apply & 建表/改表(核心同步逻辑) */
922
+ /* ========================================================================== */
923
+
924
+ /**
925
+ * SQLite 重建表迁移(简化版)
926
+ */
927
+ async function rebuildSqliteTable(runtime: SyncRuntime, tableName: string, fields: Record<string, FieldDefinition>): Promise<void> {
928
+ // 说明:SQLite ALTER TABLE 能力有限(尤其是修改列类型/默认值/约束)。
929
+ // 策略:创建临时表 -> 复制“交集列”数据 -> 删除旧表 -> 临时表改名。
930
+ // - 只复制 targetCols 与 existingCols 的交集,避免因新增列/删除列导致 INSERT 失败。
931
+ // - 不做额外的数据转换/回填:保持迁移路径尽量“纯结构同步”。
932
+ if (runtime.dbDialect !== "sqlite") {
933
+ throw new Error(`rebuildSqliteTable 仅支持 sqlite 方言,当前: ${String(runtime.dbDialect)}`);
934
+ }
935
+
936
+ const quotedSourceTable = quoteIdentifier("sqlite", tableName);
937
+ const info = await runtime.db.unsafe(`PRAGMA table_info(${quotedSourceTable})`);
938
+ const existingCols = info.map((r: any) => r.name);
939
+ const businessCols = Object.keys(fields).map((k) => snakeCase(k));
940
+ const targetCols = ["id", "created_at", "updated_at", "deleted_at", "state", ...businessCols];
941
+ const tmpTable = `${tableName}__tmp__${Date.now()}`;
942
+
943
+ await createTable(runtime, tmpTable, fields);
944
+
945
+ const commonCols = targetCols.filter((c) => existingCols.includes(c));
946
+ if (commonCols.length > 0) {
947
+ const colsSql = commonCols.map((c) => quoteIdentifier("sqlite", c)).join(", ");
948
+ const quotedTmpTable = quoteIdentifier("sqlite", tmpTable);
949
+ await runtime.db.unsafe(`INSERT INTO ${quotedTmpTable} (${colsSql}) SELECT ${colsSql} FROM ${quotedSourceTable}`);
950
+ }
951
+
952
+ await runtime.db.unsafe(`DROP TABLE ${quotedSourceTable}`);
953
+ const quotedTmpTable = quoteIdentifier("sqlite", tmpTable);
954
+ await runtime.db.unsafe(`ALTER TABLE ${quotedTmpTable} RENAME TO ${quotedSourceTable}`);
955
+ }
956
+
957
+ /**
958
+ * 将表结构计划应用到数据库
959
+ */
960
+ async function applyTablePlan(runtime: SyncRuntime, tableName: string, fields: Record<string, FieldDefinition>, plan: TablePlan): Promise<void> {
961
+ if (!plan || !plan.changed) return;
962
+
963
+ // A) 结构变更(ADD/MODIFY):SQLite 走重建表;其余方言走 ALTER TABLE
964
+ if (runtime.dbDialect === "sqlite") {
965
+ if (plan.modifyClauses.length > 0 || plan.defaultClauses.length > 0) {
966
+ await rebuildSqliteTable(runtime, tableName, fields);
967
+ } else {
968
+ for (const c of plan.addClauses) {
969
+ const stmt = `ALTER TABLE ${quoteIdentifier(runtime.dbDialect, tableName)} ${c}`;
970
+ await runtime.db.unsafe(stmt);
971
+ }
972
+ }
973
+ } else {
974
+ const clauses = [...plan.addClauses, ...plan.modifyClauses];
975
+ if (clauses.length > 0) {
976
+ const tableQuoted = quoteIdentifier(runtime.dbDialect, tableName);
977
+ const stmt = runtime.dbDialect === "mysql" ? `ALTER TABLE ${tableQuoted} ALGORITHM=INSTANT, LOCK=NONE, ${clauses.join(", ")}` : `ALTER TABLE ${tableQuoted} ${clauses.join(", ")}`;
978
+ if (runtime.dbDialect === "mysql") await executeDDLSafely(runtime.db, stmt);
979
+ else await runtime.db.unsafe(stmt);
980
+ }
981
+ }
982
+
983
+ // B) 默认值变更:SQLite 不支持在线修改默认值(需要重建表),其余方言按子句执行
984
+ if (plan.defaultClauses.length > 0) {
985
+ if (runtime.dbDialect === "sqlite") {
986
+ Logger.warn(`SQLite 不支持修改默认值,表 ${tableName} 的默认值变更已跳过`);
987
+ } else {
988
+ const tableQuoted = quoteIdentifier(runtime.dbDialect, tableName);
989
+ const stmt = runtime.dbDialect === "mysql" ? `ALTER TABLE ${tableQuoted} ALGORITHM=INSTANT, LOCK=NONE, ${plan.defaultClauses.join(", ")}` : `ALTER TABLE ${tableQuoted} ${plan.defaultClauses.join(", ")}`;
990
+ if (runtime.dbDialect === "mysql") await executeDDLSafely(runtime.db, stmt);
991
+ else await runtime.db.unsafe(stmt);
992
+ }
993
+ }
994
+
995
+ // C) 索引动作:不同方言的 DDL 策略由 buildIndexSQL 统一生成
996
+ for (const act of plan.indexActions) {
997
+ const stmt = buildIndexSQL(runtime.dbDialect, tableName, act.indexName, act.fieldName, act.action);
998
+ try {
999
+ await runtime.db.unsafe(stmt);
1000
+ if (act.action === "create") {
1001
+ Logger.debug(`[索引变化] 新建索引 ${tableName}.${act.indexName} (${act.fieldName})`);
1002
+ } else {
1003
+ Logger.debug(`[索引变化] 删除索引 ${tableName}.${act.indexName} (${act.fieldName})`);
1004
+ }
1005
+ } catch (error: any) {
1006
+ Logger.error({ err: error, table: tableName, index: act.indexName, field: act.fieldName }, `${act.action === "create" ? "创建" : "删除"}索引失败`);
1007
+ throw error;
1008
+ }
1009
+ }
1010
+
1011
+ // D) PG 列注释:独立 SQL 执行(COMMENT ON COLUMN)
1012
+ if (runtime.dbDialect === "postgresql" && plan.commentActions && plan.commentActions.length > 0) {
1013
+ for (const stmt of plan.commentActions) {
1014
+ await runtime.db.unsafe(stmt);
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ /**
1020
+ * 创建表(包含系统字段和业务字段)
1021
+ */
1022
+ async function createTable(runtime: SyncRuntime, tableName: string, fields: Record<string, FieldDefinition>, systemIndexFields: ReadonlyArray<string> = SYSTEM_INDEX_FIELDS): Promise<void> {
1023
+ const colDefs = [...buildSystemColumnDefs(runtime.dbDialect), ...buildBusinessColumnDefs(runtime.dbDialect, fields)];
1024
+
1025
+ const cols = colDefs.join(",\n ");
1026
+ const tableQuoted = quoteIdentifier(runtime.dbDialect, tableName);
1027
+ const { ENGINE, CHARSET, COLLATE } = MYSQL_TABLE_CONFIG;
1028
+ const createSQL = runtime.dbDialect === "mysql" ? `CREATE TABLE ${tableQuoted} (\n ${cols}\n ) ENGINE=${ENGINE} DEFAULT CHARSET=${CHARSET} COLLATE=${COLLATE}` : `CREATE TABLE ${tableQuoted} (\n ${cols}\n )`;
1029
+
1030
+ await runtime.db.unsafe(createSQL);
1031
+
1032
+ if (runtime.dbDialect === "postgresql") {
1033
+ for (const f of SYSTEM_FIELDS) {
1034
+ const escaped = String(f.comment).replace(/'/g, "''");
1035
+ const colQuoted = quoteIdentifier(runtime.dbDialect, f.name);
1036
+ const stmt = `COMMENT ON COLUMN ${tableQuoted}.${colQuoted} IS '${escaped}'`;
1037
+ await runtime.db.unsafe(stmt);
1038
+ }
1039
+
1040
+ for (const [fieldKey, fieldDef] of Object.entries(fields)) {
1041
+ const dbFieldName = snakeCase(fieldKey);
1042
+ const colQuoted = quoteIdentifier(runtime.dbDialect, dbFieldName);
1043
+
1044
+ const fieldName = fieldDef.name && fieldDef.name !== "null" ? String(fieldDef.name) : "";
1045
+ const escaped = fieldName.replace(/'/g, "''");
1046
+ const valueSql = fieldName ? `'${escaped}'` : "NULL";
1047
+ const stmt = `COMMENT ON COLUMN ${tableQuoted}.${colQuoted} IS ${valueSql}`;
1048
+ await runtime.db.unsafe(stmt);
1049
+ }
1050
+ }
1051
+
1052
+ const indexTasks: Array<Promise<unknown>> = [];
1053
+
1054
+ let existingIndexes: Record<string, string[]> = {};
1055
+ if (runtime.dbDialect === "mysql") {
1056
+ existingIndexes = await getTableIndexesRuntime(runtime, tableName);
1057
+ }
1058
+
1059
+ for (const sysField of systemIndexFields) {
1060
+ const indexName = `idx_${sysField}`;
1061
+ if (runtime.dbDialect === "mysql" && existingIndexes[indexName]) {
1062
+ continue;
1063
+ }
1064
+ const stmt = buildIndexSQL(runtime.dbDialect, tableName, indexName, sysField, "create");
1065
+ indexTasks.push(runtime.db.unsafe(stmt));
1066
+ }
1067
+
1068
+ for (const [fieldKey, fieldDef] of Object.entries(fields)) {
1069
+ const dbFieldName = snakeCase(fieldKey);
1070
+
1071
+ if (fieldDef.index === true && fieldDef.unique !== true) {
1072
+ const indexName = `idx_${dbFieldName}`;
1073
+ if (runtime.dbDialect === "mysql" && existingIndexes[indexName]) {
1074
+ continue;
1075
+ }
1076
+ const stmt = buildIndexSQL(runtime.dbDialect, tableName, indexName, dbFieldName, "create");
1077
+ indexTasks.push(runtime.db.unsafe(stmt));
1078
+ }
1079
+ }
1080
+
1081
+ if (indexTasks.length > 0) {
1082
+ await Promise.all(indexTasks);
1083
+ }
1084
+ }
1085
+
1086
+ async function modifyTableRuntime(runtime: SyncRuntime, tableName: string, fields: Record<string, FieldDefinition>): Promise<TablePlan> {
1087
+ // 1) 读取现有元信息(列/索引)
1088
+ const existingColumns = await getTableColumnsRuntime(runtime, tableName);
1089
+ const existingIndexes = await getTableIndexesRuntime(runtime, tableName);
1090
+
1091
+ // 2) 规划变更(先 plan,后统一 apply)
1092
+ let changed = false;
1093
+
1094
+ const addClauses: string[] = [];
1095
+ const modifyClauses: string[] = [];
1096
+ const defaultClauses: string[] = [];
1097
+ const indexActions: Array<{ action: "create" | "drop"; indexName: string; fieldName: string }> = [];
1098
+
1099
+ // 3) 对比业务字段:新增/变更(类型/长度/可空/默认值/注释)
1100
+ for (const [fieldKey, fieldDef] of Object.entries(fields)) {
1101
+ const dbFieldName = snakeCase(fieldKey);
1102
+
1103
+ if (existingColumns[dbFieldName]) {
1104
+ const comparison = compareFieldDefinition(runtime.dbDialect, existingColumns[dbFieldName], fieldDef);
1105
+ if (comparison.length > 0) {
1106
+ for (const c of comparison) {
1107
+ const changeLabel = CHANGE_TYPE_LABELS[c.type as keyof typeof CHANGE_TYPE_LABELS] || "未知";
1108
+ Logger.debug(` ~ 修改 ${dbFieldName} ${changeLabel}: ${c.current} -> ${c.expected}`);
1109
+ }
1110
+
1111
+ if (isStringOrArrayType(fieldDef.type) && existingColumns[dbFieldName].max && fieldDef.max !== null) {
1112
+ if (existingColumns[dbFieldName].max! > fieldDef.max) {
1113
+ Logger.warn(`[跳过危险变更] ${tableName}.${dbFieldName} 长度收缩 ${existingColumns[dbFieldName].max} -> ${fieldDef.max} 已被跳过(需手动处理)`);
1114
+ }
1115
+ }
1116
+
1117
+ const hasTypeChange = comparison.some((c) => c.type === "datatype");
1118
+ const hasLengthChange = comparison.some((c) => c.type === "length");
1119
+ const onlyDefaultChanged = comparison.every((c) => c.type === "default");
1120
+ const defaultChanged = comparison.some((c) => c.type === "default");
1121
+
1122
+ if (hasTypeChange) {
1123
+ const typeChange = comparison.find((c) => c.type === "datatype");
1124
+ const currentType = String(typeChange?.current || "").toLowerCase();
1125
+ const typeMapping = getTypeMapping(runtime.dbDialect);
1126
+ const expectedType = typeMapping[fieldDef.type]?.toLowerCase() || "";
1127
+
1128
+ if (!isCompatibleTypeChange(currentType, expectedType)) {
1129
+ const errorMsg = [`禁止字段类型变更: ${tableName}.${dbFieldName}`, `当前类型: ${typeChange?.current}`, `目标类型: ${typeChange?.expected}`, "说明: 仅允许宽化型变更(如 INT->BIGINT, VARCHAR->TEXT),其他类型变更需要手动处理"].join("\n");
1130
+ throw new Error(errorMsg);
1131
+ }
1132
+ Logger.debug(`[兼容类型变更] ${tableName}.${dbFieldName} ${currentType} -> ${expectedType}`);
1133
+ }
1134
+
1135
+ if (defaultChanged) {
1136
+ const actualDefault = resolveDefaultValue(fieldDef.default, fieldDef.type);
1137
+
1138
+ let v: string | null = null;
1139
+ if (actualDefault !== "null") {
1140
+ const defaultSql = generateDefaultSql(actualDefault, fieldDef.type);
1141
+ v = defaultSql.trim().replace(/^DEFAULT\s+/, "");
1142
+ }
1143
+
1144
+ if (v !== null && v !== "") {
1145
+ if (runtime.dbDialect === "postgresql") {
1146
+ const colQuoted = quoteIdentifier(runtime.dbDialect, dbFieldName);
1147
+ defaultClauses.push(`ALTER COLUMN ${colQuoted} SET DEFAULT ${v}`);
1148
+ } else if (runtime.dbDialect === "mysql" && onlyDefaultChanged) {
1149
+ if (fieldDef.type !== "text") {
1150
+ const colQuoted = quoteIdentifier(runtime.dbDialect, dbFieldName);
1151
+ defaultClauses.push(`ALTER COLUMN ${colQuoted} SET DEFAULT ${v}`);
1152
+ }
1153
+ }
1154
+ }
1155
+ }
1156
+
1157
+ if (!onlyDefaultChanged) {
1158
+ let skipModify = false;
1159
+ if (hasLengthChange && isStringOrArrayType(fieldDef.type) && existingColumns[dbFieldName].max && fieldDef.max !== null) {
1160
+ const isShrink = existingColumns[dbFieldName].max! > fieldDef.max;
1161
+ if (isShrink) skipModify = true;
1162
+ }
1163
+
1164
+ if (!skipModify) modifyClauses.push(generateDDLClause(runtime.dbDialect, fieldKey, fieldDef, false));
1165
+ }
1166
+ changed = true;
1167
+ }
1168
+ } else {
1169
+ addClauses.push(generateDDLClause(runtime.dbDialect, fieldKey, fieldDef, true));
1170
+ changed = true;
1171
+ }
1172
+ }
1173
+
1174
+ // 4) 补齐系统字段(除 id 外)
1175
+ const systemFieldNames = SYSTEM_FIELDS.filter((f) => f.name !== "id").map((f) => f.name) as string[];
1176
+ for (const sysFieldName of systemFieldNames) {
1177
+ if (!existingColumns[sysFieldName]) {
1178
+ const colDef = getSystemColumnDef(runtime.dbDialect, sysFieldName);
1179
+ if (colDef) {
1180
+ Logger.debug(` + 新增系统字段 ${sysFieldName}`);
1181
+ addClauses.push(`ADD COLUMN ${colDef}`);
1182
+ changed = true;
1183
+ }
1184
+ }
1185
+ }
1186
+
1187
+ // 5) 索引动作:系统字段索引 + 业务字段单列索引
1188
+ for (const sysField of SYSTEM_INDEX_FIELDS) {
1189
+ const idxName = `idx_${sysField}`;
1190
+ const fieldWillExist = existingColumns[sysField] || systemFieldNames.includes(sysField);
1191
+ if (fieldWillExist && !existingIndexes[idxName]) {
1192
+ indexActions.push({ action: "create", indexName: idxName, fieldName: sysField });
1193
+ changed = true;
1194
+ }
1195
+ }
1196
+
1197
+ for (const [fieldKey, fieldDef] of Object.entries(fields)) {
1198
+ const dbFieldName = snakeCase(fieldKey);
1199
+
1200
+ const indexName = `idx_${dbFieldName}`;
1201
+ if (fieldDef.index && !fieldDef.unique && !existingIndexes[indexName]) {
1202
+ indexActions.push({ action: "create", indexName: indexName, fieldName: dbFieldName });
1203
+ changed = true;
1204
+ } else if (!fieldDef.index && existingIndexes[indexName] && existingIndexes[indexName].length === 1) {
1205
+ indexActions.push({ action: "drop", indexName: indexName, fieldName: dbFieldName });
1206
+ changed = true;
1207
+ }
1208
+ }
1209
+
1210
+ // 6) PG 注释动作(MySQL 在列定义里带 COMMENT;SQLite 无列注释)
1211
+ const commentActions: string[] = [];
1212
+ if (runtime.dbDialect === "postgresql") {
1213
+ const tableQuoted = quoteIdentifier(runtime.dbDialect, tableName);
1214
+ for (const [fieldKey, fieldDef] of Object.entries(fields)) {
1215
+ const dbFieldName = snakeCase(fieldKey);
1216
+ const colQuoted = quoteIdentifier(runtime.dbDialect, dbFieldName);
1217
+
1218
+ if (existingColumns[dbFieldName]) {
1219
+ const curr = existingColumns[dbFieldName].comment || "";
1220
+ const want = fieldDef.name && fieldDef.name !== "null" ? String(fieldDef.name) : "";
1221
+ if (want !== curr) {
1222
+ const escapedWant = want.replace(/'/g, "''");
1223
+ commentActions.push(`COMMENT ON COLUMN ${tableQuoted}.${colQuoted} IS ${want ? `'${escapedWant}'` : "NULL"}`);
1224
+ changed = true;
1225
+ }
1226
+ }
1227
+ }
1228
+ }
1229
+
1230
+ // 7) 汇总计划并应用
1231
+ changed = addClauses.length > 0 || modifyClauses.length > 0 || defaultClauses.length > 0 || indexActions.length > 0 || commentActions.length > 0;
1232
+
1233
+ const plan: TablePlan = {
1234
+ changed: changed,
1235
+ addClauses: addClauses,
1236
+ modifyClauses: modifyClauses,
1237
+ defaultClauses: defaultClauses,
1238
+ indexActions: indexActions,
1239
+ commentActions: commentActions
1240
+ };
1241
+
1242
+ if (plan.changed) {
1243
+ await applyTablePlan(runtime, tableName, fields, plan);
1244
+ }
1245
+
1246
+ return plan;
1247
+ }