文档对齐 React 技术栈,并支持渐进式数据库迁移

将 README 与部署文档从仅 init 改为 001 基线 + 增量迁移约定,并补充 create/status 脚手架与序号校验。
This commit is contained in:
yml2213
2026-07-10 12:36:36 +08:00
parent ca5433ada4
commit 1155b3c608
9 changed files with 592 additions and 231 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* 创建下一份渐进式数据库迁移文件。
*
* 用法:
* npm run db:migrate:create -- add_order_note
* npm run db:migrate:create -- "add order note"
*
* 规则:
* - 001_init.sql 是基线,只用于空库首次建表
* - 后续结构变更一律新增 002_xxx.sql / 003_xxx.sql ...
* - 已应用到任何环境的迁移文件禁止修改内容
*/
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
const MIGRATIONS_DIR = path.join(CURRENT_DIR, '../src/db/migrations')
const MIGRATION_FILE_PATTERN = /^(\d{3})_(.+)\.sql$/i
function slugify(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_+/g, '_')
}
function listMigrationFiles(): string[] {
if (!fs.existsSync(MIGRATIONS_DIR)) {
throw new Error(`迁移目录不存在: ${MIGRATIONS_DIR}`)
}
return fs
.readdirSync(MIGRATIONS_DIR)
.filter((name) => MIGRATION_FILE_PATTERN.test(name))
.sort((a, b) => a.localeCompare(b, 'en'))
}
function resolveNextVersion(files: string[]): number {
let maxVersion = 0
for (const file of files) {
const match = file.match(MIGRATION_FILE_PATTERN)
if (!match) {
continue
}
maxVersion = Math.max(maxVersion, Number(match[1]))
}
return maxVersion + 1
}
function buildTemplate(version: string, name: string): string {
const stamp = new Date().toISOString()
return `-- ${version}_${name}.sql
-- 创建时间: ${stamp}
-- 说明: TODO 填写本次结构变更目的
--
-- 渐进迁移约定:
-- 1. 只写增量 SQLALTER / CREATE INDEX / 数据回填等)
-- 2. 禁止修改已经提交并应用过的历史迁移文件
-- 3. 空库会按文件名顺序执行 001_init.sql → 后续增量迁移
-- 4. 线上/开发库启动时自动应用尚未记录在 schema_migrations 中的文件
-- 在此编写增量 SQL
`
}
function main() {
const rawName = process.argv.slice(2).join(' ').trim()
if (!rawName || rawName.startsWith('-')) {
console.error('用法: npm run db:migrate:create -- <migration_name>')
console.error('示例: npm run db:migrate:create -- add_task_priority')
process.exitCode = 1
return
}
const name = slugify(rawName)
if (!name) {
console.error('迁移名称无效,请使用英文/数字/下划线描述,例如 add_task_priority')
process.exitCode = 1
return
}
const existing = listMigrationFiles()
const nextVersion = resolveNextVersion(existing)
if (nextVersion > 999) {
console.error('迁移序号已超过 999,请调整命名策略')
process.exitCode = 1
return
}
const version = String(nextVersion).padStart(3, '0')
const fileName = `${version}_${name}.sql`
const filePath = path.join(MIGRATIONS_DIR, fileName)
if (fs.existsSync(filePath)) {
console.error(`迁移文件已存在: ${fileName}`)
process.exitCode = 1
return
}
const conflict = existing.find((item) => item.startsWith(`${version}_`))
if (conflict) {
console.error(`迁移序号冲突: ${version} 已被 ${conflict} 占用`)
process.exitCode = 1
return
}
fs.writeFileSync(filePath, buildTemplate(version, name), 'utf8')
console.info(`[db:migrate:create] 已创建 ${path.relative(process.cwd(), filePath)}`)
console.info('[db:migrate:create] 编辑 SQL 后提交;后端启动或 npm run db:migrate 会自动应用')
}
main()
+102
View File
@@ -0,0 +1,102 @@
/**
* 查看本地迁移文件与数据库 schema_migrations 的对齐状态。
*
* 用法:
* npm run db:migrate:status
*/
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { closeDb, query } from '../src/db/client.js'
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
const MIGRATIONS_DIR = path.join(CURRENT_DIR, '../src/db/migrations')
const MIGRATION_FILE_PATTERN = /^(\d{3})_.+\.sql$/i
function listLocalMigrations(): string[] {
if (!fs.existsSync(MIGRATIONS_DIR)) {
return []
}
return fs
.readdirSync(MIGRATIONS_DIR)
.filter((name) => MIGRATION_FILE_PATTERN.test(name))
.sort((a, b) => a.localeCompare(b, 'en'))
}
function normalizeMigrationName(value: unknown): string {
const raw = String(value || '').trim()
if (!raw) {
return ''
}
return raw.replace(/\.sql$/i, '')
}
async function main() {
const localFiles = listLocalMigrations()
const localNames = localFiles.map((file) => file.replace(/\.sql$/i, ''))
let appliedNames = new Set<string>()
try {
const result = await query<{ name: string | null; filename: string | null; run_on: string | null }>(
`
SELECT name, filename, run_on
FROM schema_migrations
ORDER BY COALESCE(filename, name), id
`,
)
appliedNames = new Set(
result.rows
.map((row) => normalizeMigrationName(row.filename || row.name))
.filter(Boolean),
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (/schema_migrations/i.test(message) && /does not exist|不存在/i.test(message)) {
console.warn('[db:migrate:status] schema_migrations 尚不存在,视为尚未应用任何迁移')
} else {
throw error
}
}
const pending = localNames.filter((name) => !appliedNames.has(name))
const orphaned = [...appliedNames].filter((name) => !localNames.includes(name))
console.info('[db:migrate:status] 本地迁移文件')
if (localFiles.length === 0) {
console.info(' (无)')
} else {
for (const file of localFiles) {
const name = file.replace(/\.sql$/i, '')
const mark = appliedNames.has(name) ? 'applied' : 'pending'
console.info(` [${mark}] ${file}`)
}
}
console.info(`[db:migrate:status] 已应用 ${appliedNames.size} 个,待应用 ${pending.length}`)
if (pending.length > 0) {
console.info('[db:migrate:status] 待应用:')
for (const name of pending) {
console.info(` - ${name}.sql`)
}
}
if (orphaned.length > 0) {
console.warn('[db:migrate:status] 数据库中存在本地没有的迁移记录(可能是旧环境残留):')
for (const name of orphaned) {
console.warn(` - ${name}`)
}
}
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await closeDb()
})