scripts-promote-to-source-template.mjs
11.3 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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
#!/usr/bin/env node
// scripts/promote-to-source.mjs —— 绿后把副本上已验证的**新迁移**晋升到源库。
//
// 模型:新 V<n> 先在副本上被 Flyway apply 并被测试;测试绿后,本脚本把这些"副本有、源库还没有"的
// 迁移应用到源库,使源库停在"上一次通过测试的 schema + 累积数据"。源库数据绝不被清、只做 schema 追加。
//
// 做法(确定性、纯 mysql CLI、无需起后端):
// ① 取 delta = 副本 flyway_schema_history 里源库没有的版本行(success=1,按 installed_rank 升序)。
// ② 逐个:把 sql/migrations/<script> 重放到源库 + 移植该迁移的 flyway 历史行(installed_rank 取源库当前
// MAX+1,checksum/description/type/script 原样搬用——checksum 由副本上真 Flyway 算好,下次校验必过)。
// ③ 全程持本仓库根 .tmp/erp-source-promote.lock 文件锁。
//
// 串行化:并行 lane 间的真正串行由**编排层**保证——test-gate(跑 test.mjs、内含本脚本)整段被 withStackLock
// 全局起栈互斥包住,任一时刻只有一个 test-gate 在跑,故对共享源库的晋升天然串行。本脚本的文件锁只兜底
// **同一 worktree 内**的重复运行(lane worktree 各有独立 .tmp/,文件锁跨不了 worktree)。
// 幂等:已在源库的版本自动跳过(可重复运行 / 并发下双跑安全)。源库未被 Flyway 管理(无 history 表)→ 跳过并告警。
// 前提:迁移为 sql/migrations/V<n>__*.sql 纯 SQL(无 Flyway 占位符替换)。由 test.mjs 绿后调用。
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync, mkdirSync, rmSync, statSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const PROJECT_ROOT = join(SCRIPT_DIR, '..')
const CONFIG_FILE = join(PROJECT_ROOT, 'config-vars.yaml')
const MIGRATIONS_DIR = join(PROJECT_ROOT, 'sql', 'migrations')
const LOCK_DIR = join(PROJECT_ROOT, '.tmp', 'erp-source-promote.lock')
const LOG = '[promote-to-source]'
const LOCK_STALE_MS = 10 * 60 * 1000 // 持锁 >10min 视为崩溃残留,可窃取
const LOCK_WAIT_MS = 3 * 60 * 1000 // 等锁上限
// 极简 YAML 读取(2 层 map + 标量;与 scripts/setup-test-db.mjs 同规则,内联以免运行时依赖)。
function parseScalar(raw) {
let s = String(raw).trim()
if (s === '' || s[0] === '#') return ''
const q = s[0]
if (q === '"' || q === "'") {
const end = s.indexOf(q, 1)
if (end !== -1) return s.slice(1, end)
}
const hash = s.indexOf(' #')
if (hash !== -1) s = s.slice(0, hash).trim()
return s
}
function parseYamlConfig(text) {
const root = {}
let section = null
for (const rawLine of text.split('\n')) {
const line = rawLine.replace(/\r$/, '')
const trimmed = line.trim()
if (trimmed === '' || trimmed[0] === '#') continue
const colon = line.indexOf(':')
if (colon === -1) continue
const key = line.slice(0, colon).trim()
if (key === '') continue
const indent = line.length - line.replace(/^\s+/, '').length
const value = parseScalar(line.slice(colon + 1))
if (indent === 0) {
if (value === '') { section = {}; root[key] = section }
else { root[key] = value; section = null }
} else if (section) { section[key] = value }
else { root[key] = value }
}
return root
}
function quoteSqlString(value) {
return "'" + String(value).replaceAll('\\', '\\\\').replaceAll("'", "''") + "'"
}
function quoteMySqlIdent(value) {
return '`' + String(value).replaceAll('`', '``') + '`'
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
if (!existsSync(CONFIG_FILE)) {
console.error(`${LOG} config-vars.yaml 不存在(${CONFIG_FILE})`)
process.exit(1)
}
const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {}
function readLaneDbMarker() {
const markerFile = join(PROJECT_ROOT, '.tmp', 'erp-lane-db')
if (!existsSync(markerFile)) return ''
return readFileSync(markerFile, 'utf8').split('\n')[0].trim()
}
const DB_HOST = db.host ?? ''
const DB_PORT = db.port ?? '3306'
const DB_USER = db.user ?? ''
const DB_PASSWORD = db.password ?? ''
const SOURCE_SCHEMA = db.schema ?? ''
function rejectPlaceholder(key, value) {
if (typeof value === 'string' && value.includes('【人工填写')) {
console.error(`${LOG} database.${key} 仍是占位,请先在 config-vars.yaml 填真实值`)
process.exit(1)
}
}
for (const [key, value] of [['host', DB_HOST], ['port', DB_PORT], ['user', DB_USER], ['password', DB_PASSWORD], ['schema', SOURCE_SCHEMA]]) {
rejectPlaceholder(key, value)
}
if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) {
console.error(`${LOG} database.port 非法: ${DB_PORT}`)
process.exit(1)
}
if (String(SOURCE_SCHEMA).trim() === '') {
console.error(`${LOG} database.schema(源库名)未填`)
process.exit(1)
}
const COPY_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || readLaneDbMarker() || `${SOURCE_SCHEMA}__test`
if (COPY_SCHEMA === SOURCE_SCHEMA) {
console.error(`${LOG} 拒绝:副本名与源库名相同(${SOURCE_SCHEMA})——无 delta 可取。`)
process.exit(1)
}
const CONN_ARGS = [`--host=${DB_HOST}`, `--port=${DB_PORT}`, `--user=${DB_USER}`, `--password=${DB_PASSWORD}`]
// 查询型调用:mysql -N -B(tab 分隔、无表头、NULL 打印为字面 "NULL")。
function mysqlQuery(sql) {
return spawnSync('mysql', [...CONN_ARGS, '-N', '-B', '-e', sql], { encoding: 'utf8' })
}
// 应用型调用:SQL 喂 stdin,目标库作位置参数(--comments 防剥注释)。
function mysqlApply(targetSchema, sqlText) {
return spawnSync('mysql', [...CONN_ARGS, '--comments', targetSchema], { input: sqlText, encoding: 'utf8' })
}
function fatal(res, label) {
if (res.error) {
console.error(`${LOG} FATAL: 无法执行 mysql(请确认其在 PATH 中): ${res.error.message}`)
releaseLock()
process.exit(1)
}
if (res.status !== 0) {
if (res.stderr) process.stderr.write(res.stderr)
console.error(`${LOG} FAIL (${label}): mysql exit=${res.status}`)
releaseLock()
process.exit(res.status === null ? 1 : res.status)
}
}
// ── 文件锁:兜底同一 worktree 内重复运行(跨 lane 串行由编排层 withStackLock 保证,见文件头) ──
let lockHeld = false
function releaseLock() {
if (lockHeld) {
try { rmSync(LOCK_DIR, { recursive: true, force: true }) } catch { /* ignore */ }
lockHeld = false
}
}
async function acquireLock() {
mkdirSync(join(PROJECT_ROOT, '.tmp'), { recursive: true })
const deadline = Date.now() + LOCK_WAIT_MS
for (;;) {
try {
mkdirSync(LOCK_DIR) // 原子:已存在即抛 EEXIST
lockHeld = true
return
} catch (e) {
if (e?.code !== 'EEXIST') throw e
// 陈旧锁(持有者疑似崩溃)→ 窃取
try {
if (Date.now() - statSync(LOCK_DIR).mtimeMs > LOCK_STALE_MS) {
rmSync(LOCK_DIR, { recursive: true, force: true })
continue
}
} catch { /* 锁刚被别人释放,重试 */ }
if (Date.now() > deadline) {
console.error(`${LOG} 等源库晋升锁超时(${LOCK_WAIT_MS}ms)——疑有其它晋升卡住,请检查 ${LOCK_DIR}`)
process.exit(1)
}
await sleep(300)
}
}
}
// ── 主流程 ──
await acquireLock()
try {
// 源库未被 Flyway 管理 → 无从判定 delta,跳过(不因晋升让已绿的测试闸失败)。
const srcHasHistory = mysqlQuery(
`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ${quoteSqlString(SOURCE_SCHEMA)} AND table_name = 'flyway_schema_history'`,
)
fatal(srcHasHistory, 'check-source-history')
if (srcHasHistory.stdout.trim() !== '1') {
console.log(`${LOG} 源库 ${SOURCE_SCHEMA} 未被 Flyway 管理(无 flyway_schema_history),跳过晋升。`)
releaseLock()
process.exit(0)
}
// 副本无 history(本轮未起后端 / 无迁移)→ 无 delta。
const copyHasHistory = mysqlQuery(
`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ${quoteSqlString(COPY_SCHEMA)} AND table_name = 'flyway_schema_history'`,
)
fatal(copyHasHistory, 'check-copy-history')
if (copyHasHistory.stdout.trim() !== '1') {
console.log(`${LOG} 副本 ${COPY_SCHEMA} 无 flyway_schema_history,无迁移可晋升。`)
releaseLock()
process.exit(0)
}
// delta = 副本有、源库没有的版本行(success=1,按 apply 顺序)。
const deltaRes = mysqlQuery(
`SELECT c.version, c.description, c.type, c.script, c.checksum, c.execution_time ` +
`FROM ${quoteMySqlIdent(COPY_SCHEMA)}.flyway_schema_history c ` +
`WHERE c.version IS NOT NULL AND c.success = 1 ` +
`AND NOT EXISTS (SELECT 1 FROM ${quoteMySqlIdent(SOURCE_SCHEMA)}.flyway_schema_history s WHERE s.version = c.version) ` +
`ORDER BY c.installed_rank`,
)
fatal(deltaRes, 'query-delta')
const rows = deltaRes.stdout.split('\n').map((l) => l.trimEnd()).filter((l) => l !== '')
if (rows.length === 0) {
console.log(`${LOG} 源库 ${SOURCE_SCHEMA} 已含副本全部迁移,无新迁移可晋升。`)
releaseLock()
process.exit(0)
}
console.log(`${LOG} 待晋升到源库 ${SOURCE_SCHEMA} 的新迁移 ${rows.length} 个(副本 ${COPY_SCHEMA} → 源库)`)
let promoted = 0
for (const line of rows) {
const [version, description, type, script, checksum, execTime] = line.split('\t')
const migFile = join(MIGRATIONS_DIR, script)
if (!existsSync(migFile)) {
console.error(`${LOG} 迁移文件不存在,无法晋升: sql/migrations/${script}(version=${version})`)
releaseLock()
process.exit(1)
}
// 锁内二次核对(防并发已被别的进程晋升)。
const already = mysqlQuery(
`SELECT COUNT(*) FROM ${quoteMySqlIdent(SOURCE_SCHEMA)}.flyway_schema_history WHERE version = ${quoteSqlString(version)}`,
)
fatal(already, 'recheck-version')
if (already.stdout.trim() !== '0') {
console.log(`${LOG} 跳过(源库已有): V${version} ${script}`)
continue
}
// checksum / execution_time 可能为 NULL(-B 打印字面 "NULL")。
const checksumSql = (checksum === undefined || checksum === 'NULL' || checksum === '') ? 'NULL' : String(Number(checksum))
const execSql = (execTime === undefined || execTime === 'NULL' || execTime === '') ? '0' : String(Number(execTime))
// 一次调用内:重放迁移 DDL + 移植历史行(mysql 批处理遇错即停,DDL 失败则历史行不落)。
const ddl = readFileSync(migFile, 'utf8').trimEnd()
const sep = /;$/.test(ddl) ? '\n' : ';\n'
const insert =
`INSERT INTO flyway_schema_history ` +
`(installed_rank, version, description, type, script, checksum, installed_by, installed_on, execution_time, success) ` +
`SELECT COALESCE(MAX(installed_rank), 0) + 1, ${quoteSqlString(version)}, ${quoteSqlString(description ?? '')}, ` +
`${quoteSqlString(type || 'SQL')}, ${quoteSqlString(script)}, ${checksumSql}, 'promote', NOW(), ${execSql}, 1 ` +
`FROM flyway_schema_history;`
console.log(`${LOG} 晋升: V${version} ${script}`)
fatal(mysqlApply(SOURCE_SCHEMA, `${ddl}${sep}${insert}`), `promote V${version}`)
promoted += 1
}
console.log(`${LOG} done — 晋升 ${promoted} 个迁移到源库 ${SOURCE_SCHEMA}`)
} finally {
releaseLock()
}