scripts-setup-test-db-template.mjs
7.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
#!/usr/bin/env node
// scripts/setup-test-db.mjs —— 准备一个**一次性测试副本库**(不再 DROP 源库)。
//
// 新模型(源库永不被测试 drop):
// SOURCE = config-vars.yaml database.schema —— 真实持久库,只被复制(只读 mysqldump)、绝不 drop。
// COPY = 本次测试的一次性副本库(跑完由 scripts/drop-test-db.mjs 删除)。
// 本脚本 = DROP IF EXISTS <copy> → CREATE <copy> → mysqldump <source> | mysql <copy>。
// 副本带着源库当时的**全部 schema + 数据 + flyway_schema_history**;起后端时 Flyway out-of-order
// 只把源库还没有的**本轮新迁移**apply 到副本(历史里已有的自动跳过,绝不重建、绝不碰源库)。
// 绿后由 scripts/promote-to-source.mjs 把这些新迁移晋升到源库。
//
// 由 coding.mjs 的 test-gate / seed / 行为门调用。DB 凭据从仓库根 config-vars.yaml 的 database: 段读取;
// host / user / password 信任该文件,port 仅校验范围。纯 mysql/mysqldump CLI(spawnSync),零 npm 依赖。
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const CONFIG_FILE = join(SCRIPT_DIR, '..', 'config-vars.yaml')
// 极简 YAML 读取(2 层 map + 标量;与插件 lib/yaml-config.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
}
if (!existsSync(CONFIG_FILE)) {
console.error(`[setup-test-db] config-vars.yaml 不存在(${CONFIG_FILE})`)
process.exit(1)
}
const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {}
// lane 兜底 marker:并行波次执行器建好 lane worktree 后在仓库根写 .tmp/erp-lane-db(一行 lane 副本库名)。
// 副本名注入是 prompt-only(提示词链两跳),任何一跳丢 ERP_TEST_DB_SCHEMA 前缀即回退到串行缺省副本名,
// 绝不会指向源库(COPY≠SOURCE 硬守卫兜底);主根无 marker 且 env 缺省时走串行缺省副本名。
function readLaneDbMarker() {
const markerFile = join(SCRIPT_DIR, '..', '.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 ?? ''
// SOURCE = 真实持久库(只被复制,绝不 drop)。
const SOURCE_SCHEMA = db.schema ?? ''
function rejectPlaceholder(key, value) {
if (typeof value === 'string' && value.includes('【人工填写')) {
console.error(`[setup-test-db] database.${key} 仍是占位,请先在 config-vars.yaml 填真实值(database.password 可填 '' 表示空密码)`)
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(`[setup-test-db] database.port 非法: ${DB_PORT}(必须是 1..65535 的整数)`)
process.exit(1)
}
if (String(SOURCE_SCHEMA).trim() === '') {
console.error('[setup-test-db] database.schema(源库名)未填')
process.exit(1)
}
// COPY = 一次性副本库名。取值顺序 env > marker > `<source>__test`(串行缺省)。
const LANE_MARKER_SCHEMA = readLaneDbMarker()
const COPY_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || LANE_MARKER_SCHEMA || `${SOURCE_SCHEMA}__test`
// marker 是不可见的兜底路径(env 前缀在命令行上自明),生效时必须留痕便于排查指错库。
if (!process.env.ERP_TEST_DB_SCHEMA && LANE_MARKER_SCHEMA) {
console.log(`[setup-test-db] lane DB marker 生效: ${LANE_MARKER_SCHEMA}`)
}
// **安全红线**:副本名绝不能等于源库名——否则 DROP <copy> 就是 DROP 源库。任何一跳把 env/marker
// 误设成源库名、或源库名恰好等于串行缺省副本名,都在此拦下(本脚本是唯一会 DROP 的地方)。
if (COPY_SCHEMA === SOURCE_SCHEMA) {
console.error(`[setup-test-db] 拒绝:副本库名与源库名相同(${SOURCE_SCHEMA})——绝不能 DROP 源库。请把 ERP_TEST_DB_SCHEMA / .tmp/erp-lane-db 设成不同于 database.schema 的名字。`)
process.exit(1)
}
function quoteMySqlIdent(value) {
return '`' + String(value).replaceAll('`', '``') + '`'
}
const COPY_SQL = quoteMySqlIdent(COPY_SCHEMA)
console.log(`[setup-test-db] 复制源库 ${SOURCE_SCHEMA} → 一次性副本 ${COPY_SCHEMA} on ${DB_HOST}:${DB_PORT}(源库只读,绝不 drop)`)
const CONN_ARGS = [`--host=${DB_HOST}`, `--port=${DB_PORT}`, `--user=${DB_USER}`, `--password=${DB_PASSWORD}`]
function fatal(res, label) {
if (res.error) {
console.error(`[setup-test-db] FATAL: 无法执行 ${label}(请确认其在 PATH 中): ${res.error.message}`)
process.exit(1)
}
if (res.status !== 0) {
if (res.stderr) process.stderr.write(res.stderr)
console.error(`[setup-test-db] FAIL (${label}): exit=${res.status}`)
process.exit(res.status === null ? 1 : res.status)
}
}
// ① 重建空的副本库(只 DROP 副本;源库从不出现在 DROP 语句里)。
const createSql =
`DROP DATABASE IF EXISTS ${COPY_SQL}; ` +
`CREATE DATABASE ${COPY_SQL} CHARACTER SET utf8 COLLATE utf8_general_ci;`
fatal(spawnSync('mysql', [...CONN_ARGS, '-e', createSql], { stdio: 'inherit' }), 'mysql create-copy')
// ② mysqldump 源库(只读、一致快照)→ 灌进副本。argv 传参(不走 shell,密码不被 shell 暴露/需转义);
// 副本数据量通常是演示种子级别,dump 走内存 buffer 即可(大库可改为落临时文件流式导入)。
const dump = spawnSync(
'mysqldump',
[...CONN_ARGS, '--single-transaction', '--no-tablespaces', '--routines', '--triggers', '--events', '--set-gtid-purged=OFF', SOURCE_SCHEMA],
{ maxBuffer: 1024 * 1024 * 1024 },
)
fatal(dump, 'mysqldump source')
const load = spawnSync('mysql', [...CONN_ARGS, COPY_SCHEMA], { input: dump.stdout })
fatal(load, 'mysql load-copy')
console.log(`[setup-test-db] done — 副本 ${COPY_SCHEMA} 已就绪(含源库 schema+数据+flyway 历史);起后端时 Flyway 仅 apply 本轮新迁移`)