scripts-setup-test-db-template.mjs 7.3 KB
#!/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 本轮新迁移`)