From 56c4d89124d3fdebb7c3b72b684b365531237f80 Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:26:05 +0800 Subject: [PATCH] 测试库模型重构:复制源库副本替代 DROP 重建,源库永不被测试 drop --- README.md | 15 +++++++++------ lib/bootstrap-source-flyway.mjs | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/bootstrap-source-flyway.test.mjs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ lib/drop-test-db-template.test.mjs | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/promote-to-source-template.test.mjs | 44 ++++++++++++++++++++++++++++++++++++++++++++ lib/seed-demo-data-template.test.mjs | 16 ++++++++++++---- lib/setup-test-db-template.test.mjs | 48 ++++++++++++++++++++++++++---------------------- lib/test-template.test.mjs | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ skills/plan/db-init/SKILL.md | 41 ++++++++++++++--------------------------- skills/plan/project-init/templates/CLAUDE-template.md | 8 ++++---- skills/plan/project-init/templates/docs-08-initial-template.md | 2 +- skills/plan/skeleton-gen/SKILL.md | 8 +++++++- skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md | 10 +++++----- skills/plan/skeleton-gen/templates/scripts-drop-test-db-template.mjs | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ skills/plan/skeleton-gen/templates/scripts-promote-to-source-template.mjs | 263 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs | 37 +++++++++++++++++++++++++------------ skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------- skills/plan/skeleton-gen/templates/scripts-test-template.mjs | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------ workflows/coding.mjs | 84 +++++++++++++++++++++++++++++++++++++++++++++--------------------------------------- 19 files changed, 1093 insertions(+), 216 deletions(-) create mode 100644 lib/bootstrap-source-flyway.mjs create mode 100644 lib/bootstrap-source-flyway.test.mjs create mode 100644 lib/drop-test-db-template.test.mjs create mode 100644 lib/promote-to-source-template.test.mjs create mode 100644 skills/plan/skeleton-gen/templates/scripts-drop-test-db-template.mjs create mode 100644 skills/plan/skeleton-gen/templates/scripts-promote-to-source-template.mjs diff --git a/README.md b/README.md index 30fdd3d..741e3fb 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 │ throw 自然冒泡到模块主循环 try → fail-fast) │ → testGate(backend) │ → Seed stage(testGate green 后:生成 sql/seed/NN__.sql 演示种子 - │ + 冷起栈真跑验证:空库→Flyway→seed-demo-data.mjs 注入→按 -- expect: 行对账) + │ + 冷起栈真跑验证:复制源库→副本→Flyway apply 新迁移→seed-demo-data.mjs 注入 + │ →按 -- expect: 行对账→drop-test-db.mjs 删副本) │ → runCrossModule(JS 编排:diff → 分类 → 写日志) │ → reportPrompt(LLM 12 节叙述) │ → runMilestone(JS 编排:wt → default → 已合入? → merge → 字段当前值? @@ -101,7 +102,7 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 Plan 阶段**两段式**执行,中间有一个人工审阅断点(docs/03 数据库 schema): - **第一段(首次运行)**:执行 **A0 → A1 → A2 → A3**(创建骨架 / 锁技术栈 / 填需求 / 生成 REQ 卡片 / 生成项目骨架 / 从 REQ 正向设计 `docs/03-数据库设计文档.md` 并回填 REQ 依赖表)后**停下**,等你审阅 docs/03 的表 / 字段 / 索引 / 语义引用关系(人工关口:数据库 schema —— A4 会基于它翻译 DDL 并 apply 到 MySQL)。A1 的 REQ 卡片由 CC 据 index.md 填 6 个占位、字段表按模板原样复制,**不再单独停下审阅** - - **第二段(docs/03 审阅完重新运行)**:执行 **A4 → A5**(解析 docs/03 → 生成 V1 migration → 自动 `DROP+CREATE` 本地 schema 并 apply → 生成下游文档 → **docs/05 + docs/02 评审闸** → prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三),通过 **Plan 终结硬闸** 后再次**停下**(前端布局/交互以 `prototype/` 为权威,不另设 UI 规范文档) + - **第二段(docs/03 审阅完重新运行)**:执行 **A4 → A5**(解析 docs/03 → 生成 V1 migration → `bootstrap-source-flyway` 把源库建立成 Flyway 托管态(建库 + apply V1 + 写 flyway 历史;已托管则幂等跳过)→ 生成下游文档 → **docs/05 + docs/02 评审闸** → prototype/ 门禁 + 推导 FE 清单写 docs/08 § 三),通过 **Plan 终结硬闸** 后再次**停下**(前端布局/交互以 `prototype/` 为权威,不另设 UI 规范文档) Plan 完成后不会自动进入编码,需手动 /erp-workflow:coding-start。 @@ -187,7 +188,9 @@ erp-workflow-plugin/ | scope-lock | `_module-template.md` | 模块子目录的 `_module.md` 模块头(模块代码-名 / 简述 / 依赖模块 TBD / 涉及表 TBD) | | scope-lock | `config-vars-template.yaml` | 仓库根 `config-vars.yaml` 骨架(跨栈中立):项目**全部配置**——非敏感(包名/端口/前端包名/初始账号)+ 敏感凭据(database / admin_init.password / secrets);A1 E.2 锁定,随项目提交 | | skeleton-gen | `docs-04-skeleton-template.md` | docs/04 § 一+ 编码规范大纲(HTML 注释引导 LLM) | -| skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台 drop + create 空库脚本(内联极简 YAML 读 config-vars.yaml database: 段);schema apply 交给 Flyway | +| skeleton-gen | `scripts-setup-test-db-template.mjs` | 跨平台"复制源库→一次性测试副本"脚本(mysqldump 管道;内联极简 YAML 读 config-vars.yaml database: 段;COPY≠SOURCE 守卫,源库绝不被 drop);本轮新迁移的 apply 交给起后端时的 Flyway | +| skeleton-gen | `scripts-drop-test-db-template.mjs` | 测试收尾删副本脚本(幂等 DROP IF EXISTS;硬拒删源库) | +| skeleton-gen | `scripts-promote-to-source-template.mjs` | 测试绿后把副本上已验证的新迁移晋升到源库(移植 flyway 历史行 + 重放迁移 SQL;幂等;跨 lane 由编排层起栈互斥串行) | | skeleton-gen | `scripts-seed-demo-data-template.mjs` | 演示种子注入脚本(schema 建好后按 `sql/seed/__.sql` 文件名升序幂等注入;`_demo_seed_history` 账本表记已应用文件跳过;同走 mysql;调用方 = 前端 e2e globalSetup / 行为门 / 里程碑后人工验收) | | skeleton-gen | `scripts-test-template.mjs` | test.mjs 骨架(命令槽位按后端/前端/build/lint/test/e2e 分开,`spawnSync(shell:true)` 跨平台执行) | | skeleton-gen | `gitignore-append-template` | 插件推荐忽略项(`.tmp/`、构建产物等;config-vars.yaml 随项目提交,不忽略) | @@ -202,9 +205,9 @@ erp-workflow-plugin/ ## 前置依赖 - **Node.js ≥ 18**:`lib/*.mjs` 助手 + 生成进目标项目的 `scripts/*.mjs` 为 Node ESM;`workflows/coding.mjs` 是 Claude Workflow 运行时脚本(由 `Workflow` 工具执行,不作为普通 `node` CLI 入口)。A0 `project-init` 检测 git / mysql / node 在 PATH,缺失则按 OS 自动安装,装不上再停下提示用户 -- **MySQL 8.x** 实例已就绪(host / 库名 / 凭据取自 `config-vars.yaml` 的 `database:` 段,由你填写并完全信任;本项目只面向开发/沙盒环境,`setup-test-db.mjs` 会按该值 DROP+CREATE)。生成的 `scripts/seed-demo-data.mjs`(演示种子注入)同走 `mysql` CLI、同读该 `database:` 段,故 `mysql` 客户端须在 PATH -- **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/apply-ddl.mjs` 用 mysql2 连接 + 解析 config-vars.yaml `database:` 段 apply V1;生成的 `scripts/setup-test-db.mjs` 在测试闸门前后 drop+create 空库 -- **Spring Boot + Flyway**(**必需**):build.gradle 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。本插件生成的 `setup-test-db.mjs` 只清库,schema 必须由 Flyway 应用 +- **MySQL 8.x** 实例已就绪(host / 库名 / 凭据取自 `config-vars.yaml` 的 `database:` 段,由你填写并完全信任)。`database.schema` = **源库**(真实持久库):测试**永不 drop 它**,每次测试由 `setup-test-db.mjs` 把它 `mysqldump` 复制成一次性副本(`__test` / lane 副本),跑完 `drop-test-db.mjs` 删副本,绿后 `promote-to-source.mjs` 把新迁移晋升回源库。这些脚本与 `seed-demo-data.mjs` 同走 `mysql`/`mysqldump` CLI,故两者须在 PATH +- **`mysql2`(目标项目侧)**:A4 `db-init` 经 `lib/bootstrap-source-flyway.mjs` 用 mysql2 建源库 + apply V1 + 写 checksum 一致的 flyway 历史(源库首次 Flyway 托管化;已托管则幂等跳过) +- **Spring Boot + Flyway**(**必需**):build.gradle 声明 `flyway-core` + `flyway-mysql`;Spring 启动时自动 apply `sql/migrations/V*.sql`。副本自带源库 flyway 历史,起后端时 Flyway 只 apply 本轮新迁移到副本 - **本地 git 仓库**(纯本地,无需远程):A0 `project-init` 执行 `git init`;B 阶段每模块由 `coding.mjs` 的 milestone stage 本地 `git merge --no-ff` 进默认分支并 `git tag -a milestone/`,完成信号由 `git tag -l` 判定。**不依赖任何远程仓库 / push / GitLab** - **本地可运行 `./gradlew test` / `pnpm test`**:测试命令由 A1 写入 docs/04 § 零,生成的 `scripts/test.mjs` 由 `skeleton-gen` 产出,`coding.mjs` 的 testGate stage 调用 diff --git a/lib/bootstrap-source-flyway.mjs b/lib/bootstrap-source-flyway.mjs new file mode 100644 index 0000000..ebdc6c7 --- /dev/null +++ b/lib/bootstrap-source-flyway.mjs @@ -0,0 +1,177 @@ +// lib/bootstrap-source-flyway.mjs —— 把**源库**建立成 Flyway 托管态(Plan 期 db-init 调用)。 +// +// 复制测试模型需要一个已存在、且被 Flyway 托管(有 flyway_schema_history)的源库才能复制。但 Plan 阶段 +// 没有后端 app 可起、跑不了真 Flyway。本脚本用纯 mysql2 完成等效初始化: +// ① CREATE DATABASE IF NOT EXISTS (源库); +// ② 若已有 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__.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 的 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 ') + 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) + } +} diff --git a/lib/bootstrap-source-flyway.test.mjs b/lib/bootstrap-source-flyway.test.mjs new file mode 100644 index 0000000..bffd1e4 --- /dev/null +++ b/lib/bootstrap-source-flyway.test.mjs @@ -0,0 +1,50 @@ +// lib/bootstrap-source-flyway.test.mjs — 纯函数单测(checksum / 迁移名解析)。 +// 完整 bootstrap 需真实 mysql2 + DB,不在离线范围;此处只锁定与 Flyway 对齐的关键不变量。 +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { flywayChecksum, parseMigrationName } from './bootstrap-source-flyway.mjs' + +test('flywayChecksum: deterministic and returns a signed int32', () => { + const a = flywayChecksum('CREATE TABLE t (id INT);\n') + const b = flywayChecksum('CREATE TABLE t (id INT);\n') + assert.equal(a, b) + assert.equal(Number.isInteger(a), true) + assert.ok(a >= -2147483648 && a <= 2147483647, 'signed int32 range') +}) + +// Flyway 按行读取(剥行终止符)——CRLF 与 LF 的同一内容 checksum 必须相同(跨平台 checkout 稳定)。 +test('flywayChecksum: CRLF and LF of the same content match (line-ending normalized)', () => { + const lf = 'line1\nline2\nline3\n' + const crlf = 'line1\r\nline2\r\nline3\r\n' + assert.equal(flywayChecksum(lf), flywayChecksum(crlf)) +}) + +// 尾部有无换行不改变行集合末尾(BufferedReader 不返回"最后的空行")——单个尾换行与无尾换行一致。 +test('flywayChecksum: a single trailing newline does not change the checksum', () => { + assert.equal(flywayChecksum('a\nb'), flywayChecksum('a\nb\n')) +}) + +// 内容不同 → checksum 不同(基本区分性)。 +test('flywayChecksum: different content yields different checksum', () => { + assert.notEqual(flywayChecksum('SELECT 1;'), flywayChecksum('SELECT 2;')) +}) + +// 首行 UTF-8 BOM 被剥离(与 Flyway 一致)——带 BOM 与不带 BOM 的同一内容一致。 +test('flywayChecksum: leading UTF-8 BOM is stripped', () => { + assert.equal(flywayChecksum('CREATE TABLE t (id INT);\n'), flywayChecksum('CREATE TABLE t (id INT);\n')) +}) + +test('parseMigrationName: V1__initial_schema.sql → version 1, description "initial schema"', () => { + assert.deepEqual(parseMigrationName('V1__initial_schema.sql'), { version: '1', description: 'initial schema' }) +}) + +test('parseMigrationName: dotted/underscored versions normalize to dots', () => { + assert.deepEqual(parseMigrationName('V2_1__add_index.sql'), { version: '2.1', description: 'add index' }) + assert.deepEqual(parseMigrationName('V10.3__foo.sql'), { version: '10.3', description: 'foo' }) +}) + +test('parseMigrationName: non-migration filenames return null', () => { + assert.equal(parseMigrationName('seed.sql'), null) + assert.equal(parseMigrationName('V1_no_double_underscore.sql'), null) + assert.equal(parseMigrationName('R__repeatable.sql'), null) +}) diff --git a/lib/drop-test-db-template.test.mjs b/lib/drop-test-db-template.test.mjs new file mode 100644 index 0000000..067aca4 --- /dev/null +++ b/lib/drop-test-db-template.test.mjs @@ -0,0 +1,64 @@ +// lib/drop-test-db-template.test.mjs — 校验生成模板 scripts/drop-test-db.mjs 的"只删副本、硬拒删源库"逻辑。 +// 跑真实模板产物:复制到临时 scripts/、写 ../config-vars.yaml、再 node 执行。host/port 指向 127.0.0.1:1(必拒连)。 +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-drop-test-db-template.mjs', import.meta.url)) + +function runWithDb({ host = '127.0.0.1', port = '1', user = 'root', password = 'x', schemaLine = 'schema: erp_dev', env = {}, laneMarker = null } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'erp-drtb-')) + mkdirSync(join(dir, 'scripts')) + copyFileSync(TEMPLATE, join(dir, 'scripts', 'drop-test-db.mjs')) + writeFileSync( + join(dir, 'config-vars.yaml'), + ['database:', ` host: ${host}`, ` port: ${port}`, ` user: ${user}`, ` password: ${password}`, ' ' + schemaLine, ''].join('\n'), + ) + if (laneMarker !== null) { + mkdirSync(join(dir, '.tmp')) + writeFileSync(join(dir, '.tmp', 'erp-lane-db'), laneMarker) + } + const childEnv = { ...process.env, ...env } + if (!('ERP_TEST_DB_SCHEMA' in env)) delete childEnv.ERP_TEST_DB_SCHEMA + return spawnSync('node', [join(dir, 'scripts', 'drop-test-db.mjs')], { encoding: 'utf8', env: childEnv }) +} + +test('drop-test-db: unfilled placeholders fail before mysql', () => { + const r = runWithDb({ schemaLine: 'schema: 【人工填写:schema 名】' }) + assert.equal(r.status, 1) + assert.match(r.stderr, /仍是占位/, 'stderr: ' + r.stderr) +}) + +test('drop-test-db: empty source schema fails before mysql', () => { + const r = runWithDb({ schemaLine: 'schema:' }) + assert.equal(r.status, 1) + assert.match(r.stderr, /database\.schema/, 'stderr: ' + r.stderr) +}) + +// **安全红线**:待删副本 = 源库 → 拒绝(绝不删源库)。守卫先于任何 mysql 调用。 +test('drop-test-db: refuses to drop when copy name equals source (protects source)', () => { + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev' } }) + assert.equal(r.status, 1) + assert.match(r.stderr, /待删副本名与源库名相同/, 'stderr: ' + r.stderr) + assert.doesNotMatch(r.stdout, /删除一次性副本/, '拒绝应先于 DROP — stdout: ' + r.stdout) +}) + +test('drop-test-db: serial default targets __test copy', () => { + const r = runWithDb() + assert.match(r.stdout, /删除一次性副本 erp_dev__test/, 'stdout: ' + r.stdout) + assert.notEqual(r.status, 0, '应在连库阶段失败(127.0.0.1:1)') +}) + +test('drop-test-db: ERP_TEST_DB_SCHEMA env names the copy to drop', () => { + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } }) + assert.match(r.stdout, /删除一次性副本 erp_dev_lane2/, 'stdout: ' + r.stdout) +}) + +test('drop-test-db: .tmp/erp-lane-db marker names the copy when env absent', () => { + const r = runWithDb({ laneMarker: 'erp_dev_lane3\n' }) + assert.match(r.stdout, /删除一次性副本 erp_dev_lane3/, 'stdout: ' + r.stdout) +}) diff --git a/lib/promote-to-source-template.test.mjs b/lib/promote-to-source-template.test.mjs new file mode 100644 index 0000000..7917525 --- /dev/null +++ b/lib/promote-to-source-template.test.mjs @@ -0,0 +1,44 @@ +// lib/promote-to-source-template.test.mjs — 校验生成模板 scripts/promote-to-source.mjs 的离线守卫。 +// 只覆盖任何 mysql 调用 / 文件锁之前的本地校验(占位 / 源库名空 / COPY===SOURCE 守卫); +// delta 计算与迁移重放需真实 DB,不在离线测试范围。host/port 指向 127.0.0.1:1(必拒连)。 +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-promote-to-source-template.mjs', import.meta.url)) + +function runWithDb({ host = '127.0.0.1', port = '1', user = 'root', password = 'x', schemaLine = 'schema: erp_dev', env = {} } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'erp-prom-')) + mkdirSync(join(dir, 'scripts')) + copyFileSync(TEMPLATE, join(dir, 'scripts', 'promote-to-source.mjs')) + writeFileSync( + join(dir, 'config-vars.yaml'), + ['database:', ` host: ${host}`, ` port: ${port}`, ` user: ${user}`, ` password: ${password}`, ' ' + schemaLine, ''].join('\n'), + ) + const childEnv = { ...process.env, ...env } + if (!('ERP_TEST_DB_SCHEMA' in env)) delete childEnv.ERP_TEST_DB_SCHEMA + return spawnSync('node', [join(dir, 'scripts', 'promote-to-source.mjs')], { encoding: 'utf8', env: childEnv }) +} + +test('promote-to-source: unfilled placeholders fail before lock/mysql', () => { + const r = runWithDb({ schemaLine: 'schema: 【人工填写:schema 名】' }) + assert.equal(r.status, 1) + assert.match(r.stderr, /仍是占位/, 'stderr: ' + r.stderr) +}) + +test('promote-to-source: empty source schema fails before lock/mysql', () => { + const r = runWithDb({ schemaLine: 'schema:' }) + assert.equal(r.status, 1) + assert.match(r.stderr, /database\.schema/, 'stderr: ' + r.stderr) +}) + +// COPY===SOURCE 守卫先于文件锁与任何 mysql 调用。 +test('promote-to-source: copy name equal to source is rejected before lock/mysql', () => { + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev' } }) + assert.equal(r.status, 1) + assert.match(r.stderr, /副本名与源库名相同/, 'stderr: ' + r.stderr) +}) diff --git a/lib/seed-demo-data-template.test.mjs b/lib/seed-demo-data-template.test.mjs index 1d27cf8..0379ce1 100644 --- a/lib/seed-demo-data-template.test.mjs +++ b/lib/seed-demo-data-template.test.mjs @@ -113,19 +113,19 @@ test('seed-demo-data: well-formed __.sql passes filename check', const r = run({ seedFiles: { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } }) assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr) - assert.match(r.stdout, /目标库 erp_dev/, '应已进入 DB 阶段 — stdout: ' + r.stdout) + assert.match(r.stdout, /目标库 erp_dev__test/, '串行缺省目标应为副本 erp_dev__test(非源库)— stdout: ' + r.stdout) }) test('seed-demo-data: valid seed + port 1 passes local checks then fails at DB connect', () => { const r = run({ seedFiles: { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } }) assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) // 已通过全部本地校验、进入 DB 阶段:应打印目标库摘要,且报错是连库失败(非占位/非法名)。 - assert.match(r.stdout, /目标库 erp_dev/, '应已进入 DB 阶段 — stdout: ' + r.stdout) + assert.match(r.stdout, /目标库 erp_dev__test/, '串行缺省目标应为副本 erp_dev__test(非源库)— stdout: ' + r.stdout) assert.doesNotMatch(r.stderr, /仍是占位/, 'stderr: ' + r.stderr) assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr) }) -// ── 测试 DB lane 化:取值顺序 env > .tmp/erp-lane-db marker > config ── +// ── 注入目标副本 lane 化:取值顺序 env > .tmp/erp-lane-db marker > `__test` ── const LANE_SEED = { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } @@ -145,6 +145,14 @@ test('seed-demo-data: env wins over marker; blank marker falls through to config assert.match(both.stdout, /目标库 erp_dev_lane2/, 'stdout: ' + both.stdout) assert.doesNotMatch(both.stdout, /lane DB marker 生效/, 'stdout: ' + both.stdout) const blank = run({ seedFiles: LANE_SEED, laneMarker: '\n' }) - assert.match(blank.stdout, /目标库 erp_dev/, 'stdout: ' + blank.stdout) + assert.match(blank.stdout, /目标库 erp_dev__test/, '空 marker 应回退到副本 erp_dev__test — stdout: ' + blank.stdout) assert.doesNotMatch(blank.stdout, /lane DB marker 生效/, 'stdout: ' + blank.stdout) }) + +// **安全红线**:注入目标 = 源库 → 拒绝(演示种子绝不写源库)。守卫先于任何 mysql 调用。 +test('seed-demo-data: injection target equal to source schema is rejected before mysql', () => { + const r = run({ seedFiles: LANE_SEED, env: { ERP_TEST_DB_SCHEMA: 'erp_dev' } }) // 与源库同名 + assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) + assert.match(r.stderr, /注入目标副本名与源库名相同/, 'stderr: ' + r.stderr) + assert.doesNotMatch(r.stdout, /目标库/, '拒绝应发生在进入 DB 阶段之前 — stdout: ' + r.stdout) +}) diff --git a/lib/setup-test-db-template.test.mjs b/lib/setup-test-db-template.test.mjs index 6ceb410..1653c89 100644 --- a/lib/setup-test-db-template.test.mjs +++ b/lib/setup-test-db-template.test.mjs @@ -1,6 +1,6 @@ -// lib/setup-test-db-template.test.mjs — 校验生成模板 scripts/setup-test-db.mjs 的最小 DB reset 逻辑。 +// lib/setup-test-db-template.test.mjs — 校验生成模板 scripts/setup-test-db.mjs 的"复制源库→一次性副本"逻辑。 // 跑的是真实模板产物:复制到临时 scripts/ 下、写一个 ../config-vars.yaml、再 node 执行。 -// 所有用例的 host/port 故意指向 127.0.0.1:1(必拒连),不会触碰真实库。 +// 所有用例的 host/port 故意指向 127.0.0.1:1(必拒连),不会触碰真实库;本地校验先于任何 mysql 调用。 import { test } from 'node:test' import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' @@ -34,7 +34,7 @@ function runWithDb({ host = '127.0.0.1', port = '1', user = 'root', password = ' return spawnSync('node', [join(dir, 'scripts', 'setup-test-db.mjs')], { encoding: 'utf8', env: childEnv }) } -test('setup-test-db: empty schema fails before mysql is invoked', () => { +test('setup-test-db: empty schema (source) fails before mysql is invoked', () => { const r = runWithSchema('schema:') assert.equal(r.status, 1) assert.match(r.stderr, /database\.schema/, '应是 schema 缺失报错而非连库失败 — stderr: ' + r.stderr) @@ -54,48 +54,52 @@ test('setup-test-db: unfilled 【人工填写】 config placeholders fail before } }) -test('setup-test-db: schema with a backtick is quoted for MySQL instead of rejected', () => { - const r = runWithSchema('schema: ev`il') - assert.match(r.stdout, /`ev``il`/, 'stdout: ' + r.stdout) - assert.doesNotMatch(r.stderr, /database\.schema/, 'stderr: ' + r.stderr) +// **安全红线**:副本名 = 源库名 → 拒绝(否则 DROP 副本 = DROP 源库)。守卫先于任何 mysql 调用。 +test('setup-test-db: copy name equal to source is rejected before mysql (protects source)', () => { + const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev' } }) // 与 config 的源库同名 + assert.equal(r.status, 1) + assert.match(r.stderr, /副本库名与源库名相同/, 'stderr: ' + r.stderr) + assert.doesNotMatch(r.stdout, /复制源库/, '拒绝应发生在打印复制计划之前 — stdout: ' + r.stdout) }) -test('setup-test-db: an ordinary dev schema proceeds to mysql', () => { +test('setup-test-db: an ordinary dev schema copies source → __test copy', () => { const r = runWithSchema('schema: erp_dev') - assert.match(r.stdout, /DROP \+ CREATE `erp_dev`/, 'stdout: ' + r.stdout) + assert.match(r.stdout, /复制源库 erp_dev → 一次性副本 erp_dev__test/, 'stdout: ' + r.stdout) + assert.notEqual(r.status, 0, '应在连库阶段失败(127.0.0.1:1 拒连)') }) -test('setup-test-db: non-test names are allowed in sandbox/dev workflows', () => { - const r = runWithSchema('schema: erp_prod') - assert.match(r.stdout, /DROP \+ CREATE `erp_prod`/, 'stdout: ' + r.stdout) +test('setup-test-db: a schema with a backtick still resolves and reaches the copy step', () => { + const r = runWithSchema('schema: ev`il') + assert.match(r.stdout, /复制源库 ev`il → 一次性副本 ev`il__test/, 'stdout: ' + r.stdout) + assert.doesNotMatch(r.stderr, /database\.schema/, 'stderr: ' + r.stderr) }) -// ── 测试 DB lane 化:取值顺序 env > .tmp/erp-lane-db marker > config ── +// ── 副本库名 lane 化:取值顺序 env > .tmp/erp-lane-db marker > `__test` ── -test('setup-test-db: ERP_TEST_DB_SCHEMA env overrides config schema', () => { +test('setup-test-db: ERP_TEST_DB_SCHEMA env names the copy', () => { const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } }) - assert.match(r.stdout, /DROP \+ CREATE `erp_dev_lane2`/, 'stdout: ' + r.stdout) + assert.match(r.stdout, /复制源库 erp_dev → 一次性副本 erp_dev_lane2/, 'stdout: ' + r.stdout) }) -test('setup-test-db: empty ERP_TEST_DB_SCHEMA env falls through to config schema', () => { +test('setup-test-db: empty ERP_TEST_DB_SCHEMA env falls through to __test', () => { const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: '' } }) - assert.match(r.stdout, /DROP \+ CREATE `erp_dev`/, 'stdout: ' + r.stdout) + assert.match(r.stdout, /一次性副本 erp_dev__test/, 'stdout: ' + r.stdout) }) -test('setup-test-db: .tmp/erp-lane-db marker overrides config when env is absent, with audit log', () => { +test('setup-test-db: .tmp/erp-lane-db marker names the copy when env is absent, with audit log', () => { const r = runWithDb({ laneMarker: 'erp_dev_lane3\n' }) assert.match(r.stdout, /lane DB marker 生效: erp_dev_lane3/, 'stdout: ' + r.stdout) - assert.match(r.stdout, /DROP \+ CREATE `erp_dev_lane3`/, 'stdout: ' + r.stdout) + assert.match(r.stdout, /一次性副本 erp_dev_lane3/, 'stdout: ' + r.stdout) }) test('setup-test-db: env wins over marker (marker log absent)', () => { const r = runWithDb({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' }, laneMarker: 'erp_dev_lane3\n' }) - assert.match(r.stdout, /DROP \+ CREATE `erp_dev_lane2`/, 'stdout: ' + r.stdout) + assert.match(r.stdout, /一次性副本 erp_dev_lane2/, 'stdout: ' + r.stdout) assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout) }) -test('setup-test-db: blank marker falls through to config schema', () => { +test('setup-test-db: blank marker falls through to __test', () => { const r = runWithDb({ laneMarker: '\n' }) - assert.match(r.stdout, /DROP \+ CREATE `erp_dev`/, 'stdout: ' + r.stdout) + assert.match(r.stdout, /一次性副本 erp_dev__test/, 'stdout: ' + r.stdout) assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout) }) diff --git a/lib/test-template.test.mjs b/lib/test-template.test.mjs index 11b604e..2c63b74 100644 --- a/lib/test-template.test.mjs +++ b/lib/test-template.test.mjs @@ -1,7 +1,7 @@ -// lib/test-template.test.mjs — 校验生成模板 scripts/test.mjs 的测试 DB lane 化贯通逻辑。 -// D5 的要害是 env 必须真正打穿到后端测试子进程(gradle fork / e2e globalSetup→bootRun 均靠继承 env, -// 由 application*.yml 的 ${ERP_TEST_DB_SCHEMA:...} 占位消费)——故用探针命令替换 {{...}} 占位 -// (模拟 skeleton-gen 的 Plan 期填充),直接断言子进程眼中的 ERP_TEST_DB_SCHEMA。 +// lib/test-template.test.mjs — 校验生成模板 scripts/test.mjs 的副本库贯通 + 晋升/删副本时序。 +// 要害:① 副本库名(ERP_TEST_DB_SCHEMA)必须真正打穿到每个测试子进程(gradle fork / e2e globalSetup→bootRun +// 均靠继承 env,由 application*.yml 的 ${ERP_TEST_DB_SCHEMA:__test} 占位消费);② 绿后晋升、无论红绿删副本。 +// 用探针/桩命令替换 {{...}} 占位(模拟 skeleton-gen 的 Plan 期填充),直接断言子进程眼中的 ERP_TEST_DB_SCHEMA。 import { test } from 'node:test' import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' @@ -12,24 +12,39 @@ import { fileURLToPath } from 'node:url' const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-test-template.mjs', import.meta.url)) -// 探针脚本:以子进程身份打印自己继承到的 ERP_TEST_DB_SCHEMA(unset 显式可断言)。 -// 占位填充值不能带引号(模板里占位嵌在 JS 单引号字符串内),故用脚本文件而非内联 node -e。 +// 探针:以子进程身份打印自己继承到的 ERP_TEST_DB_SCHEMA(unset 显式可断言)。用于 setup-db + build/lint/test + e2e。 const PROBE_SOURCE = `console.log('PROBE_SCHEMA=' + (process.env.ERP_TEST_DB_SCHEMA ?? '(unset)'))\n` +// 晋升 / 删副本桩:打印各自可辨识的标记(用于断言"绿才晋升、无论红绿都删")。 +const PROMOTE_STUB = `console.log('PROMOTE_RAN=' + (process.env.ERP_TEST_DB_SCHEMA ?? '(unset)'))\n` +const DROP_STUB = `console.log('DROP_RAN=' + (process.env.ERP_TEST_DB_SCHEMA ?? '(unset)'))\n` -// env:传给闸门进程的增量环境变量;laneMarker:非 null 时写 .tmp/erp-lane-db 兜底标记。 +// env:传给闸门进程的增量环境变量;laneMarker:非 null 时写 .tmp/erp-lane-db 兜底标记; +// sourceSchema:非 null 时写 config-vars.yaml(database.schema),触发串行缺省副本派生; +// failBackendTest:把后端单测桩换成必失败命令,验证红路径(跳晋升、仍删副本)。 // 基础环境强制剔除 ERP_TEST_DB_SCHEMA——外层 shell 若恰好设了该变量,"缺省行为"用例会被污染。 -function runGate({ env = {}, laneMarker = null } = {}) { +function runGate({ env = {}, laneMarker = null, sourceSchema = null, failBackendTest = false } = {}) { const dir = mkdtempSync(join(tmpdir(), 'erp-gate-')) mkdirSync(join(dir, 'scripts')) mkdirSync(join(dir, 'backend')) - // backend_* 在 backend/ 子目录下执行、e2e 在仓库根执行,相对路径相应不同(frontend_* 因无 frontend/ 被跳过)。 - const filled = readFileSync(TEMPLATE, 'utf8') + // backend_* 在 backend/ 子目录下执行、e2e / promote / drop 在仓库根执行(相对路径相应不同;frontend_* 因无 frontend/ 被跳过)。 + let filled = readFileSync(TEMPLATE, 'utf8') + if (failBackendTest) filled = filled.replace('{{backend_test}}', 'node ../scripts/fail.mjs') + filled = filled .replace(/\{\{(backend|frontend)_[a-z_]+\}\}/g, 'node ../scripts/probe.mjs') .replace(/\{\{e2e_cmd\}\}/g, 'node scripts/probe.mjs') writeFileSync(join(dir, 'scripts', 'test.mjs'), filled) writeFileSync(join(dir, 'scripts', 'probe.mjs'), PROBE_SOURCE) - // setup-test-db stub:真模板会连库失败中断闸门、且自身也读 marker,无法隔离 test.mjs 的回填路径。 + writeFileSync(join(dir, 'scripts', 'fail.mjs'), 'process.exit(1)\n') + // 真模板会连库失败中断闸门,无法隔离 test.mjs 的编排逻辑——故 setup/promote/drop 全用桩。 writeFileSync(join(dir, 'scripts', 'setup-test-db.mjs'), PROBE_SOURCE) + writeFileSync(join(dir, 'scripts', 'promote-to-source.mjs'), PROMOTE_STUB) + writeFileSync(join(dir, 'scripts', 'drop-test-db.mjs'), DROP_STUB) + if (sourceSchema !== null) { + writeFileSync( + join(dir, 'config-vars.yaml'), + ['database:', ' host: 127.0.0.1', ' port: 3306', ' user: root', ' password: x', ` schema: ${sourceSchema}`, ''].join('\n'), + ) + } if (laneMarker !== null) { mkdirSync(join(dir, '.tmp')) writeFileSync(join(dir, '.tmp', 'erp-lane-db'), laneMarker) @@ -39,11 +54,17 @@ function runGate({ env = {}, laneMarker = null } = {}) { return spawnSync('node', [join(dir, 'scripts', 'test.mjs')], { encoding: 'utf8', env: childEnv }) } -test('test.mjs: ERP_TEST_DB_SCHEMA env reaches every child process', () => { +// 核心 5 段(setup-db + backend build/lint/test + e2e)各打印一次 PROBE_SCHEMA;晋升/删副本另有自己的标记。 +function countProbe(stdout, value) { + return (stdout.match(new RegExp('PROBE_SCHEMA=' + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length +} + +test('test.mjs: ERP_TEST_DB_SCHEMA env reaches every child, then promotes + drops on green', () => { const r = runGate({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } }) assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) - // setup-db / build / lint / unit / e2e 五步各打印一次。 - assert.equal((r.stdout.match(/PROBE_SCHEMA=erp_dev_lane2/g) || []).length, 5, 'stdout: ' + r.stdout) + assert.equal(countProbe(r.stdout, 'erp_dev_lane2'), 5, '5 段核心应各见一次副本名 — stdout: ' + r.stdout) + assert.match(r.stdout, /PROMOTE_RAN=erp_dev_lane2/, '绿后应晋升 — stdout: ' + r.stdout) + assert.match(r.stdout, /DROP_RAN=erp_dev_lane2/, '应删副本 — stdout: ' + r.stdout) assert.match(r.stdout, /GREEN/, 'stdout: ' + r.stdout) }) @@ -51,19 +72,41 @@ test('test.mjs: .tmp/erp-lane-db marker backfills env for children when env is a const r = runGate({ laneMarker: 'erp_dev_lane3\n' }) assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) assert.match(r.stdout, /lane DB marker 生效: ERP_TEST_DB_SCHEMA=erp_dev_lane3/, 'stdout: ' + r.stdout) - assert.equal((r.stdout.match(/PROBE_SCHEMA=erp_dev_lane3/g) || []).length, 5, 'stdout: ' + r.stdout) + assert.equal(countProbe(r.stdout, 'erp_dev_lane3'), 5, 'stdout: ' + r.stdout) + assert.match(r.stdout, /PROMOTE_RAN=erp_dev_lane3/, 'stdout: ' + r.stdout) + assert.match(r.stdout, /DROP_RAN=erp_dev_lane3/, 'stdout: ' + r.stdout) }) test('test.mjs: env wins over marker (marker log absent)', () => { const r = runGate({ env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' }, laneMarker: 'erp_dev_lane3\n' }) assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) - assert.equal((r.stdout.match(/PROBE_SCHEMA=erp_dev_lane2/g) || []).length, 5, 'stdout: ' + r.stdout) + assert.equal(countProbe(r.stdout, 'erp_dev_lane2'), 5, 'stdout: ' + r.stdout) assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout) }) -test('test.mjs: no env and no marker leaves children unset (serial behavior unchanged)', () => { +// 串行缺省:无 env/marker 但有 config-vars → 从 database.schema 派生副本 `__test` 并显式导出到所有子进程。 +test('test.mjs: serial default derives __test from config and exports it to every child', () => { + const r = runGate({ sourceSchema: 'erp_dev' }) + assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) + assert.match(r.stdout, /串行缺省副本库: ERP_TEST_DB_SCHEMA=erp_dev__test/, 'stdout: ' + r.stdout) + assert.equal(countProbe(r.stdout, 'erp_dev__test'), 5, '子进程应连副本而非源库 — stdout: ' + r.stdout) + assert.match(r.stdout, /PROMOTE_RAN=erp_dev__test/, 'stdout: ' + r.stdout) + assert.match(r.stdout, /DROP_RAN=erp_dev__test/, 'stdout: ' + r.stdout) +}) + +test('test.mjs: no env, no marker, no config leaves children unset (graceful degrade)', () => { const r = runGate() assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) - assert.equal((r.stdout.match(/PROBE_SCHEMA=\(unset\)/g) || []).length, 5, 'stdout: ' + r.stdout) + assert.equal(countProbe(r.stdout, '(unset)'), 5, 'stdout: ' + r.stdout) assert.doesNotMatch(r.stdout, /lane DB marker 生效/, 'stdout: ' + r.stdout) + assert.doesNotMatch(r.stdout, /串行缺省副本库/, 'stdout: ' + r.stdout) +}) + +// 红路径:某段失败 → 不晋升(源库不被污染),但 finally 仍删副本。 +test('test.mjs: a failing stage skips promotion but still drops the copy', () => { + const r = runGate({ sourceSchema: 'erp_dev', failBackendTest: true }) + assert.notEqual(r.status, 0, '失败段应让闸门非零退出 — stdout: ' + r.stdout + ' stderr: ' + r.stderr) + assert.doesNotMatch(r.stdout, /PROMOTE_RAN=/, '红则绝不晋升到源库 — stdout: ' + r.stdout) + assert.match(r.stdout, /DROP_RAN=erp_dev__test/, '无论红绿都删副本 — stdout: ' + r.stdout) + assert.doesNotMatch(r.stdout, /GREEN/, 'stdout: ' + r.stdout) }) diff --git a/skills/plan/db-init/SKILL.md b/skills/plan/db-init/SKILL.md index 84aa09e..8fb98c1 100644 --- a/skills/plan/db-init/SKILL.md +++ b/skills/plan/db-init/SKILL.md @@ -1,6 +1,6 @@ --- name: db-init -description: A4 DB 初始化——LLM 解析 docs/03-数据库设计文档.md → 生成 sql/migrations/V1__initial_schema.sql(DDL only,Flyway 初始 migration)→ 用 lib/validate-ddl.mjs 做 4 维校验(表/列/类型/索引)DDL ↔ docs/03 一致性 → 调 scripts/setup-test-db.mjs DROP+CREATE 空库 → 用 lib/apply-ddl.mjs apply V1。 +description: A4 DB 初始化——LLM 解析 docs/03-数据库设计文档.md → 生成 sql/migrations/V1__initial_schema.sql(DDL only,Flyway 初始 migration)→ 用 lib/validate-ddl.mjs 做 4 维校验(表/列/类型/索引)DDL ↔ docs/03 一致性 → 调 lib/bootstrap-source-flyway.mjs 把源库建立成 Flyway 托管态(建库 + apply V1 烟测 + 写 flyway 历史)。 user-invocable: false allowed-tools: Read Write Edit Skill Bash(node *) Bash(npm i mysql2) Bash(npm install mysql2) --- @@ -67,42 +67,28 @@ node "${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs" \ - ` - [ ] sql/migrations/V1__initial_schema.sql 已生成` - ` - [ ] DDL ↔ docs/03 4 维一致(validate-ddl.mjs)` -### B. 自动导入 MySQL +### B. 把源库建立成 Flyway 托管态(一步 bootstrap) -不做测试库命名预检:本项目只作用于开发或沙盒环境,`setup-test-db.mjs` / `apply-ddl.mjs` 会用同一份 `config-vars.yaml` 直接执行。若 DB 配置仍含 `【人工填写】` 占位则命令直接失败;缺 mysql 客户端、mysql2、凭据或连通性问题由实际命令报错后停下。 +不做测试库命名预检:本项目只作用于开发或沙盒环境,命令用同一份 `config-vars.yaml` 直接执行。若 DB 配置仍含 `【人工填写】` 占位则命令直接失败;缺 mysql 客户端、mysql2、凭据或连通性问题由实际命令报错后停下。 -#### B.1 DROP+CREATE 空库 +**测试模型(源库永不被测试 drop)**:Coding 阶段每次测试从**源库**(`config-vars.yaml` 的 `database.schema`)复制一份一次性副本、在副本上跑、跑完删副本;源库只被复制、绝不被 drop。这要求源库先存在且**被 Flyway 托管**(有 `flyway_schema_history`)。本步骤负责源库的**首次建立**:建库 + apply V1 + 写入正确 checksum 的 Flyway 历史,使源库自此成为 Flyway 托管的持久库。 -```bash -node scripts/setup-test-db.mjs -``` - -#### B.2 把 V1 灌入已清空的 schema +> **注意**:不再用 `scripts/setup-test-db.mjs` 建库——新模型下它是「复制源库→副本」而非「DROP+CREATE 源库」,不适合 Plan 期建源库。 -调 `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`:它读取 `config-vars.yaml` 的 `database:` 段,再经 mysql2 把 DDL 灌入 schema。 +调 `${CLAUDE_PLUGIN_ROOT}/lib/bootstrap-source-flyway.mjs`:它读 `config-vars.yaml` 的 `database:` 段,用 mysql2 建源库、按版本序 apply `sql/migrations/V*.sql`(apply V1 本身即「V1 DDL 能否被真库接受」的烟测)、并为每个迁移写一条 checksum 与 Flyway 完全一致的历史行(首次起后端时 Flyway 校验通过、不会报 checksum mismatch,只 apply 之后新增的迁移)。 ```bash -node "${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs" config-vars.yaml sql/migrations/V1__initial_schema.sql +node "${CLAUDE_PLUGIN_ROOT}/lib/bootstrap-source-flyway.mjs" config-vars.yaml sql/migrations ``` 退出码与处理: -- `0` → 成功,进入步骤 B.3 -- `1` → 失败:打印 stderr 并停下 +- `0` → 成功(含「源库已有 flyway_schema_history → 幂等跳过」) +- `1` → 失败:打印 stderr 并停下(如 V1 DDL 错、或源库「有表但无 flyway 历史」的非托管既有库需人工处置) - `2` → 用法错(路径找不到),打印路径并停下 -勾选:` - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行` - -#### B.3 清库交还 Flyway - -B.1 → B.2 是脱离 Flyway 的 DDL 烟测(直接 mysql2 灌入 V1,**绕过 Flyway**,库里没有 `flyway_schema_history` 表)。这种状态会卡住后端首次启动:若后端集成测试连真库,Spring Boot 启动时 Flyway 会对一个**非空且无 schema 历史表**的库报 `Found non-empty schema "" without schema history table`。所以烟测做完要把产物清掉、把 schema 交还 Flyway,让它在后端启动时自行重放 migration。 - -再跑一次 `setup-test-db.mjs`(DROP+CREATE,把 B.2 灌入的烟测产物连同 schema 一并清空): - -```bash -node scripts/setup-test-db.mjs -``` +> **幂等 / 安全**:源库已被 Flyway 托管即跳过、绝不重灌或 drop 源库数据;源库有表但无 `flyway_schema_history`(疑为非 Flyway 托管的既有库)→ 拒绝自动 bootstrap 并提示人工确认(避免 apply V1 撞已存在的表)。 -> B.2 的 DDL 烟测语义不变——它仍是「V1 能否被真库接受」的一次性验证;B.3 只是把烟测留下的非 Flyway 状态清掉,schema 由后端启动时的 Flyway 重新建立。 +勾选:` - [ ] 源库已 Flyway 托管化(bootstrap-source-flyway:建库 + apply V1 + 写 flyway 历史)` ### C. 勾选 docs/08 进度 + 进入 A5 @@ -114,8 +100,9 @@ node scripts/setup-test-db.mjs ## 参考 - `${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs`(A.3 docs/03 ↔ V1.sql 4 维一致性校验,跨平台纯 Node) -- `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`(B.2 读取 config-vars.yaml 的 database: 段 + mysql2 灌入 DDL) -- `${CLAUDE_PLUGIN_ROOT}/lib/yaml-config.mjs`(apply-ddl 依赖的极简 YAML 读取) +- `${CLAUDE_PLUGIN_ROOT}/lib/bootstrap-source-flyway.mjs`(B 把源库建立成 Flyway 托管态:建库 + apply V*.sql + 写 checksum 一致的 flyway 历史) +- `${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs`(bootstrap 复用其 mysql2 解析与 database 配置读取) +- `${CLAUDE_PLUGIN_ROOT}/lib/yaml-config.mjs`(极简 YAML 读取) - `docs/03-数据库设计文档.md`(DDL 翻译输入,SSoT) - `config-vars.yaml`(DB 凭据,A1 产出) - 产物:`sql/migrations/V1__initial_schema.sql`(由 Flyway 在 Spring Boot 启动时验证 / apply) diff --git a/skills/plan/project-init/templates/CLAUDE-template.md b/skills/plan/project-init/templates/CLAUDE-template.md index a589f09..3c85119 100644 --- a/skills/plan/project-init/templates/CLAUDE-template.md +++ b/skills/plan/project-init/templates/CLAUDE-template.md @@ -31,9 +31,9 @@ 1. **文件命名**:`sql/migrations/V__.sql`,例:`V5__add_user_email_unique_index.sql` 2. **版本号分配**:建文件前 `ls sql/migrations/V*.sql` 查当前最大 n,新文件 `n_max + 1` -3. **Apply 方式**:Spring Boot 启动 / 测试启动时 Flyway 自动 apply(项目必须在 `build.gradle` 声明 `flyway-core` + `flyway-mysql` 依赖)。`scripts/setup-test-db.mjs` 只负责清空库,不做 apply +3. **Apply 方式**:Spring Boot 启动 / 测试启动时 Flyway 自动 apply(项目必须在 `build.gradle` 声明 `flyway-core` + `flyway-mysql` 依赖)。`scripts/setup-test-db.mjs` 把**源库复制成一次性副本**(不做 apply),Flyway 在起后端时只把源库还没有的新迁移 apply 到副本;测试绿后 `scripts/promote-to-source.mjs` 把新迁移晋升到源库 4. **已合并的 migration 永不修改**:如果发现错了,写一个补救 migration(如 `V7__fix_V5_index_name.sql`)修正,旧 `V_n.sql` 保持原样、永不回改 -5. **临时调试 DDL**:临时在本地试字段/索引可手动 `mysql -e`,但不写 migration;下次 `setup-test-db.mjs` 会 drop+create 清掉 +5. **临时调试 DDL**:临时在本地试字段/索引可手动 `mysql -e`,但不写 migration;下次测试从源库重新复制副本,本地手改不进副本(副本跑完即删) 6. **A4 生成的 V1**:`V1__initial_schema.sql` 是 A 阶段由 `db-init` 从 `docs/03-数据库设计文档.md`(A3 正向设计的 schema SSoT)翻译生成的初始版本;后续 V2/V3/... 由 B 阶段每个 REQ 按需写入,**同时**反向同步更新 docs/03 对应表小节以保持 SSoT 一致 7. **增量需求新表/新列**:Plan 完结后用 `/erp-workflow:add-req` 追加需求时,其新表 / 新列同样落为新的 `V_n`(`ls V*.sql` 取 max+1)追加式 migration,并同步 docs/03——**永不改 V1**;schema 由下次 Coding 冷起栈时 Flyway 统一 apply @@ -41,9 +41,9 @@ ## 🌱 演示数据(demo seed) -1. **人工验收/演示三步走**:`node scripts/setup-test-db.mjs`(DROP+CREATE 空库)→ 起后端(Spring Boot 启动时 Flyway 建 schema)→ `node scripts/seed-demo-data.mjs`(注入演示种子) +1. **人工验收/演示四步走**:`node scripts/setup-test-db.mjs`(复制源库→一次性副本)→ 起后端(Spring Boot 启动时 Flyway 把新迁移 apply 到副本)→ `node scripts/seed-demo-data.mjs`(把新种子灌进副本)→ 看完 `node scripts/drop-test-db.mjs`(删副本) 2. **种子来源**:`sql/seed/*.sql` 由 B 阶段每个后端模块完成后自动生成、随 git 提交;演示数据用确定性显式主键,区间 `1000–9999` -3. **数据不持久**:库会被各测试闸门 DROP+CREATE 随时重建——种子不保证存活,只保证随时可复现;重建后按上述时序重新注入即可 +3. **数据不持久**:每次测试都从源库复制一份新副本、跑完即删(**源库绝不被 drop**)——种子不保证存活,只保证随时可复现;重新复制副本后按上述时序重新注入即可 4. **幂等账本**:账本表 `_demo_seed_history` 由 `seed-demo-data.mjs` 自建自管,重复执行自动跳过已应用文件 --- diff --git a/skills/plan/project-init/templates/docs-08-initial-template.md b/skills/plan/project-init/templates/docs-08-initial-template.md index cedce9b..28bc625 100644 --- a/skills/plan/project-init/templates/docs-08-initial-template.md +++ b/skills/plan/project-init/templates/docs-08-initial-template.md @@ -28,7 +28,7 @@ - [ ] A4 DB 初始化 — db-init - [ ] sql/migrations/V1__initial_schema.sql 已生成 - [ ] DDL ↔ docs/03 4 维一致(validate-ddl.mjs) - - [ ] setup-test-db.mjs DROP+CREATE + apply V1 已执行 + - [ ] 源库已 Flyway 托管化(bootstrap-source-flyway:建库 + apply V1 + 写 flyway 历史) - [ ] A5 下游文档生成 — downstream-gen - [ ] docs/02 开发计划已生成 diff --git a/skills/plan/skeleton-gen/SKILL.md b/skills/plan/skeleton-gen/SKILL.md index 926e6ed..b636e50 100644 --- a/skills/plan/skeleton-gen/SKILL.md +++ b/skills/plan/skeleton-gen/SKILL.md @@ -39,10 +39,14 @@ docs/04 已由 scope-lock 写入 § 零。本步骤追加 § 一 ~ 三: | 模板 | 目标路径 | |---|---| | `${CLAUDE_SKILL_DIR}/templates/scripts-setup-test-db-template.mjs` | `scripts/setup-test-db.mjs` | +| `${CLAUDE_SKILL_DIR}/templates/scripts-drop-test-db-template.mjs` | `scripts/drop-test-db.mjs` | +| `${CLAUDE_SKILL_DIR}/templates/scripts-promote-to-source-template.mjs` | `scripts/promote-to-source.mjs` | | `${CLAUDE_SKILL_DIR}/templates/scripts-seed-demo-data-template.mjs` | `scripts/seed-demo-data.mjs` | | `${CLAUDE_SKILL_DIR}/templates/styles-tokens-template.css` | `src/styles/tokens.css` | -> `scripts/seed-demo-data.mjs`:把 Coding 阶段逐模块生成的 `sql/seed/*.sql` 演示种子注入**已建好 schema** 的库;用 `_demo_seed_history` 账本表记录已应用文件以保证幂等(重复执行自动跳过);调用方 = 前端 e2e 的 globalSetup / 行为门 / 里程碑后人工验收。 +> **测试库模型(源库永不被测试 drop)**:`scripts/setup-test-db.mjs` 把源库(config-vars `database.schema`)复制成一次性副本库(`ERP_TEST_DB_SCHEMA` / marker / `__test`);测试跑在副本上;`scripts/drop-test-db.mjs` 跑完删副本(硬拒删源库);测试绿后 `scripts/promote-to-source.mjs` 把副本上已验证的本轮新迁移晋升到源库(文件锁串行化并行 lane)。四个脚本共享同一命名约定,均自带 `COPY≠SOURCE` 安全守卫。 + +> `scripts/seed-demo-data.mjs`:把 Coding 阶段逐模块生成的 `sql/seed/*.sql` 演示种子注入**副本库**(副本继承源库 `_demo_seed_history` 账本,源库已灌过的自动跳过、只补新种子);调用方 = 前端 e2e 的 globalSetup / 行为门 / 里程碑后人工验收。 > `sql/migrations/` 目录不在此预建:A4 `db-init` 写 `V1__initial_schema.sql` 时 `Write` 会自动创建。 @@ -139,6 +143,8 @@ QA 横幅涵盖:产出文件清单(docs/04 + scripts/*.mjs + .gitignore;co - `${CLAUDE_SKILL_DIR}/templates/docs-04-skeleton-template.md` - `${CLAUDE_SKILL_DIR}/templates/scripts-test-template.mjs` - `${CLAUDE_SKILL_DIR}/templates/scripts-setup-test-db-template.mjs` +- `${CLAUDE_SKILL_DIR}/templates/scripts-drop-test-db-template.mjs` +- `${CLAUDE_SKILL_DIR}/templates/scripts-promote-to-source-template.mjs` - `${CLAUDE_SKILL_DIR}/templates/scripts-seed-demo-data-template.mjs` - `config-vars.yaml`(A1 产出,含 DB 凭据;setup-test-db.mjs 运行时读取) - `${CLAUDE_SKILL_DIR}/templates/gitignore-append-template` diff --git a/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md b/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md index da9df6e..a0d6726 100644 --- a/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md +++ b/skills/plan/skeleton-gen/templates/docs-04-skeleton-template.md @@ -4,8 +4,8 @@ - 【人工填写:一条后端通用约定,按需复制本行新增更多;无则填「无」】 **测试库 env 贯通(锁定约定,生成时原样保留)**: -- 后端 `application*.yml`(含测试 profile)JDBC URL 的 schema 段一律写成 Spring 占位 `${ERP_TEST_DB_SCHEMA:}`(如 `jdbc:mysql://localhost:3306/${ERP_TEST_DB_SCHEMA:erp_dev}?...`)。env 缺省回退原 schema、串行行为不变;并行 lane 经该 env 把整条测试/起栈链指向 lane 库——gradle test fork 的 JVM 与 bootRun 默认继承 env,无需拼 `SPRING_DATASOURCE_URL`。 -- Flyway 配置 `out-of-order: true`:并行版本段下版本号**只保唯一、不保连续、不保合并序**;独立模块的 migration 互不引用(正是同波次并行的准入条件),乱序 apply 安全。 +- 后端 `application*.yml`(含测试 profile)JDBC URL 的 schema 段一律写成 Spring 占位 `${ERP_TEST_DB_SCHEMA:__test}`(如 `jdbc:mysql://localhost:3306/${ERP_TEST_DB_SCHEMA:erp_dev__test}?...`)。**缺省值必须是副本库 `__test`,绝不能是源库 `` 本身**——测试永远连一次性副本,绝不连、绝不改源库。测试/起栈链经 ERP_TEST_DB_SCHEMA env 指向本次副本(并行 lane 各自副本);gradle test fork 的 JVM 与 bootRun 默认继承 env,无需拼 `SPRING_DATASOURCE_URL`。 +- Flyway 配置 `out-of-order: true`:副本从源库复制而来、自带源库的 `flyway_schema_history`,起后端时 Flyway 只把源库还没有的**本轮新迁移**apply 到副本(历史里已有的自动跳过)。并行版本段下版本号**只保唯一、不保连续、不保合并序**;独立模块的 migration 互不引用(正是同波次并行的准入条件),乱序 apply 安全。 ### 1.2 分层结构 @@ -50,6 +50,6 @@ - 演示种子 SQL 放 `sql/seed/`,命名 `__.sql`(NN=两位序号,按模块构建顺序;随 git 提交)。 - 注入由 `scripts/seed-demo-data.mjs` 负责:B 阶段每个后端模块完成后生成对应 seed 文件,脚本逐文件按名升序应用。 - 主键区间约定:`1–999`=初始数据(admin_init 等)/ `1000–9999`=演示种子 / `≥100000`=行为验收 sentinel;三段互不重叠,演示数据值不得含 `_S<数字>` 编码串(预留给 sentinel)。 -- 注入时序恒为:`node scripts/setup-test-db.mjs`(DROP+CREATE 空库)→ 起后端(Flyway 建 schema)→ `node scripts/seed-demo-data.mjs`。 -- e2e 基线 = 演示种子已注入(前端 Playwright globalSetup 走上述时序);后端单测/集成测基线 = 空库,不注入种子。 -- 幂等账本表 `_demo_seed_history` 由 `seed-demo-data.mjs` 自建自管,已应用文件自动跳过;库被各测试闸门 DROP+CREATE 重建后按上述时序重新注入即可(数据可复现、不持久)。 +- 注入时序恒为:`node scripts/setup-test-db.mjs`(复制源库→一次性副本)→ 起后端(Flyway apply 本轮新迁移到副本)→ `node scripts/seed-demo-data.mjs`(把源库还没有的新种子灌进副本)。 +- e2e 基线 = 源库副本 + 本轮新迁移 + 补充演示种子(前端 Playwright globalSetup 走上述时序);后端单测/集成测基线 = 源库副本 + 本轮新迁移。 +- 幂等账本表 `_demo_seed_history` 由 `seed-demo-data.mjs` 自建自管:副本继承源库账本,源库已灌过的种子自动跳过、只补新种子;副本跑完即删(`scripts/drop-test-db.mjs`),**源库绝不被 drop**(数据可复现、不持久)。测试绿后由 `scripts/promote-to-source.mjs` 把本轮新迁移晋升到源库。 diff --git a/skills/plan/skeleton-gen/templates/scripts-drop-test-db-template.mjs b/skills/plan/skeleton-gen/templates/scripts-drop-test-db-template.mjs new file mode 100644 index 0000000..ccfdea8 --- /dev/null +++ b/skills/plan/skeleton-gen/templates/scripts-drop-test-db-template.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// scripts/drop-test-db.mjs —— 删除一次性测试副本库(跑完收尾;源库绝不被删)。 +// +// 与 scripts/setup-test-db.mjs 同一命名约定: +// SOURCE = config-vars.yaml database.schema(真实持久库,本脚本硬拒删除)。 +// COPY = ERP_TEST_DB_SCHEMA(env) > .tmp/erp-lane-db(marker) > `__test`(串行缺省)。 +// 由 test.mjs / seed / 行为门 runner 的 finally 调用(做完测试直接删副本)。幂等:副本不存在也返回 0。 +// 纯 mysql 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 + 标量;与 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 +} + +if (!existsSync(CONFIG_FILE)) { + console.error(`[drop-test-db] config-vars.yaml 不存在(${CONFIG_FILE})`) + process.exit(1) +} + +const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {} + +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 ?? '' +const SOURCE_SCHEMA = db.schema ?? '' + +function rejectPlaceholder(key, value) { + if (typeof value === 'string' && value.includes('【人工填写')) { + console.error(`[drop-test-db] 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(`[drop-test-db] database.port 非法: ${DB_PORT}(必须是 1..65535 的整数)`) + process.exit(1) +} + +if (String(SOURCE_SCHEMA).trim() === '') { + console.error('[drop-test-db] database.schema(源库名)未填') + process.exit(1) +} + +const LANE_MARKER_SCHEMA = readLaneDbMarker() +const COPY_SCHEMA = process.env.ERP_TEST_DB_SCHEMA || LANE_MARKER_SCHEMA || `${SOURCE_SCHEMA}__test` + +// **安全红线**:绝不删源库。副本名与源库同名 → 拒绝(说明副本名注入出错)。 +if (COPY_SCHEMA === SOURCE_SCHEMA) { + console.error(`[drop-test-db] 拒绝:待删副本名与源库名相同(${SOURCE_SCHEMA})——绝不删源库。`) + process.exit(1) +} + +function quoteMySqlIdent(value) { + return '`' + String(value).replaceAll('`', '``') + '`' +} + +console.log(`[drop-test-db] 删除一次性副本 ${COPY_SCHEMA} on ${DB_HOST}:${DB_PORT}(源库 ${SOURCE_SCHEMA} 不动)`) + +const res = spawnSync( + 'mysql', + [`--host=${DB_HOST}`, `--port=${DB_PORT}`, `--user=${DB_USER}`, `--password=${DB_PASSWORD}`, '-e', `DROP DATABASE IF EXISTS ${quoteMySqlIdent(COPY_SCHEMA)};`], + { stdio: 'inherit' }, +) +if (res.error) { + console.error(`[drop-test-db] FATAL: 无法执行 mysql(请确认其在 PATH 中): ${res.error.message}`) + process.exit(1) +} +if (res.status !== 0) { + console.error(`[drop-test-db] FAIL: mysql exit=${res.status}`) + process.exit(res.status === null ? 1 : res.status) +} + +console.log('[drop-test-db] done') diff --git a/skills/plan/skeleton-gen/templates/scripts-promote-to-source-template.mjs b/skills/plan/skeleton-gen/templates/scripts-promote-to-source-template.mjs new file mode 100644 index 0000000..1160c04 --- /dev/null +++ b/skills/plan/skeleton-gen/templates/scripts-promote-to-source-template.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +// scripts/promote-to-source.mjs —— 绿后把副本上已验证的**新迁移**晋升到源库。 +// +// 模型:新 V 先在副本上被 Flyway apply 并被测试;测试绿后,本脚本把这些"副本有、源库还没有"的 +// 迁移应用到源库,使源库停在"上一次通过测试的 schema + 累积数据"。源库数据绝不被清、只做 schema 追加。 +// +// 做法(确定性、纯 mysql CLI、无需起后端): +// ① 取 delta = 副本 flyway_schema_history 里源库没有的版本行(success=1,按 installed_rank 升序)。 +// ② 逐个:把 sql/migrations/