bootstrap-source-flyway.mjs
8.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
// lib/bootstrap-source-flyway.mjs —— 把**源库**建立成 Flyway 托管态(Plan 期 db-init 调用)。
//
// 复制测试模型需要一个已存在、且被 Flyway 托管(有 flyway_schema_history)的源库才能复制。但 Plan 阶段
// 没有后端 app 可起、跑不了真 Flyway。本脚本用纯 mysql2 完成等效初始化:
// ① CREATE DATABASE IF NOT EXISTS <source>(源库);
// ② 若已有 flyway_schema_history → 幂等跳过(已托管,绝不重灌 / 绝不 DROP);
// ③ 否则:建 flyway_schema_history(Flyway 10 的表结构)→ 按版本序 apply sql/migrations/V*.sql →
// 为每个迁移写一条历史行,**checksum 用与 Flyway 完全一致的 CRC32 复算**,使首次起后端时 Flyway
// 校验通过(不会报 checksum mismatch)、且只 apply 之后新增的迁移。
//
// apply V1 本身就是 db-init 的「V1 DDL 烟测」:V1 有 DDL 错会在此 apply 失败暴露。
// 凭据从 config-vars.yaml 的 database: 段读取。mysql2 从目标项目解析(见 apply-ddl.resolveMysql2Path)。
import { parseYamlConfig } from './yaml-config.mjs'
import { resolveMysql2Path, resolveDbConfig, MysqlUnavailableError } from './apply-ddl.mjs'
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { crc32 } from 'node:zlib'
import { pathToFileURL } from 'node:url'
import { dirname, resolve as resolvePath, join } from 'node:path'
/**
* Flyway SQL-migration checksum:与 Flyway(org.flywaydb)对 SQL 迁移的算法一致——
* 按行读取(BufferedReader.readLine 语义:\n / \r / \r\n 均为行终止符且被剥离;首行剥 UTF-8 BOM),
* 对每行的 UTF-8 字节累进 CRC32,最终以**有符号 int32** 返回(Flyway 的 checksum 列是 signed int)。
* 因按行读取,CRLF 与 LF 版本的同一内容 checksum 相同(行终止符归一化)。
* @param {string} content 迁移文件文本
* @returns {number} 有符号 32 位校验和
*/
export function flywayChecksum(content) {
let text = content
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1) // 剥首行 UTF-8 BOM
const lines = text.split(/\r\n|\r|\n/)
// 文件以行终止符结尾时 split 会多出一个尾部空串——BufferedReader 不会返回这个"最后的空行",去掉。
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
let crc = 0
for (const line of lines) crc = crc32(Buffer.from(line, 'utf8'), crc)
return crc | 0 // 转有符号 int32
}
/** 解析 `V<version>__<description>.sql` → { version, description }(下划线在版本里当分隔符、在描述里当空格)。 */
export function parseMigrationName(filename) {
const m = filename.match(/^V(\d+(?:[._]\d+)*)__(.+)\.sql$/i)
if (!m) return null
return { version: m[1].replace(/_/g, '.'), description: m[2].replace(/_/g, ' ') }
}
/** 版本号数值序比较(逐段比较,与 Flyway 一致;段数不同时短的在前)。 */
function compareVersions(a, b) {
const pa = a.split('.').map(Number)
const pb = b.split('.').map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0)
if (d !== 0) return d
}
return 0
}
// Flyway 10 (MySQL) 的 flyway_schema_history 建表 DDL —— 结构与 Flyway 自建一致,使其在首次起后端时识别复用。
const FLYWAY_HISTORY_DDL =
'CREATE TABLE `flyway_schema_history` (' +
'`installed_rank` INT NOT NULL,' +
'`version` VARCHAR(50),' +
'`description` VARCHAR(200) NOT NULL,' +
'`type` VARCHAR(20) NOT NULL,' +
'`script` VARCHAR(1000) NOT NULL,' +
'`checksum` INT,' +
'`installed_by` VARCHAR(100) NOT NULL,' +
'`installed_on` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,' +
'`execution_time` INT NOT NULL,' +
'`success` BOOL NOT NULL,' +
'CONSTRAINT `flyway_schema_history_pk` PRIMARY KEY (`installed_rank`)' +
') ENGINE=InnoDB'
function quoteIdent(value) {
return '`' + String(value).replaceAll('`', '``') + '`'
}
/**
* 把源库建立成 Flyway 托管态。幂等:已有 flyway_schema_history 即跳过。
* @param {{configPath: string, migrationsDir: string}} opts
* @returns {Promise<{bootstrapped: boolean, reason?: string, applied?: string[]}>}
*/
export async function bootstrapSourceFlyway({ configPath, migrationsDir }) {
const config = parseYamlConfig(readFileSync(configPath, 'utf8'))
const { host, port, user, password, database } = resolveDbConfig(config, configPath)
let mysql
try {
const resolved = resolveMysql2Path(dirname(resolvePath(configPath)))
;({ default: mysql } = await import(pathToFileURL(resolved).href))
} catch {
throw new MysqlUnavailableError()
}
// ① 无 database 连接,建源库(存在则不动)。
const admin = await mysql.createConnection({ host, port, user, password })
try {
await admin.query(`CREATE DATABASE IF NOT EXISTS ${quoteIdent(database)} CHARACTER SET utf8 COLLATE utf8_general_ci`)
} finally {
await admin.end()
}
const conn = await mysql.createConnection({ host, port, user, password, database, multipleStatements: true })
try {
// ② 已被 Flyway 管理 → 幂等跳过(绝不重灌、绝不 DROP 源库数据)。
const [rows] = await conn.query(
"SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = ? AND table_name = 'flyway_schema_history'",
[database],
)
if (Number(rows[0].n) > 0) {
return { bootstrapped: false, reason: 'already-managed' }
}
// 源库有业务表但无 flyway 历史 = 非 Flyway 托管的既有库——不安全 bootstrap(apply V1 会撞已存在的表)。
const [tblRows] = await conn.query(
'SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = ?',
[database],
)
if (Number(tblRows[0].n) > 0) {
throw new Error(
`源库 ${database} 已有表但无 flyway_schema_history —— 疑为非 Flyway 托管的既有库。` +
`拒绝自动 bootstrap(apply V1 会撞已存在的表)。请人工确认后清空该库、或改用已托管的库。`,
)
}
// ③ 建历史表 + 按版本序 apply 迁移 + 移植正确 checksum 的历史行。
await conn.query(FLYWAY_HISTORY_DDL)
const files = existsSync(migrationsDir)
? readdirSync(migrationsDir).filter((f) => /^V.*\.sql$/i.test(f))
: []
const parsed = files
.map((f) => ({ file: f, meta: parseMigrationName(f) }))
.filter((x) => x.meta)
.sort((a, b) => compareVersions(a.meta.version, b.meta.version))
const applied = []
let rank = 0
for (const { file, meta } of parsed) {
const sql = readFileSync(join(migrationsDir, file), 'utf8')
await conn.query(sql) // apply DDL(同时是 V<n> 的 DDL 烟测)
rank += 1
await conn.query(
'INSERT INTO flyway_schema_history ' +
'(installed_rank, version, description, type, script, checksum, installed_by, installed_on, execution_time, success) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), ?, 1)',
[rank, meta.version, meta.description, 'SQL', file, flywayChecksum(sql), 'bootstrap', 0],
)
applied.push(meta.version)
}
return { bootstrapped: true, applied }
} finally {
await conn.end()
}
}
// CLI entry guard(与 apply-ddl 同款 pathToFileURL 规范化)。
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [configPath, migrationsDir] = process.argv.slice(2)
if (!configPath || !migrationsDir) {
console.error('usage: node lib/bootstrap-source-flyway.mjs <configPath> <migrationsDir>')
process.exit(2)
}
if (!existsSync(configPath)) { console.error(`bootstrap-source-flyway: 配置文件不存在: ${configPath}`); process.exit(2) }
try {
const r = await bootstrapSourceFlyway({ configPath, migrationsDir })
if (r.bootstrapped) console.log(`bootstrap-source-flyway: 源库已 Flyway 托管化,apply 迁移 ${r.applied.length} 个: ${r.applied.map((v) => 'V' + v).join(', ') || '(无)'}`)
else console.log(`bootstrap-source-flyway: 跳过(${r.reason === 'already-managed' ? '源库已有 flyway_schema_history,已托管' : r.reason})`)
} catch (e) {
if (e instanceof MysqlUnavailableError) {
console.error('bootstrap-source-flyway: mysql2 not found. Please run `npm i mysql2` in the target project.')
process.exit(1)
}
console.error(`bootstrap-source-flyway: failed — ${e?.message || e}`)
process.exit(1)
}
}