Files
order_site/apps/backend/src/db/migrate.ts
T
yml2213 bb060e81a4 统一领取流:Step1 填 UID,按平台分叉履约
领取页改为统一 UID 入口;lewan 强制角色 ID 匹配后才发货核销,feifei 将 uid 拼入 H5 并去掉 claim 短链,对外仍返回本站 claimUrl。
2026-07-10 13:30:22 +08:00

196 lines
5.5 KiB
TypeScript

/**
* 数据库迁移入口。
*
* 策略:
* - 001_init.sql:空库基线,建立完整结构
* - 002_xxx.sql 起:渐进增量迁移,禁止改已应用历史文件
* - 启动时自动 up;也可用 npm run db:migrate 手动执行
* - 新建迁移:npm run db:migrate:create -- <name>
* - 查看状态: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 { runner, type RunnerOption } from 'node-pg-migrate'
import type { ClientConfig } from 'pg'
import { runtimeConfig } from '../config/runtime.js'
import { closeDb, query } from './client.js'
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
const MIGRATIONS_DIR = path.join(CURRENT_DIR, 'migrations')
const MIGRATIONS_TABLE = 'schema_migrations'
const SHORT_NUMERIC_PREFIX_MESSAGE = /^Can't determine timestamp for \d+$/
const MIGRATION_FILE_PATTERN = /^(\d{3})_.+\.sql$/i
export async function runDatabaseMigrations() {
assertMigrationFilesOrdered()
await ensureNodePgMigrateMetadataCompatibility()
console.info(`[db:migrate] 迁移目录: ${MIGRATIONS_DIR}`)
await runner({
databaseUrl: createMigrationDatabaseConfig(),
dir: MIGRATIONS_DIR,
direction: 'up',
migrationsTable: MIGRATIONS_TABLE,
migrationsSchema: 'public',
schema: 'public',
singleTransaction: true,
checkOrder: true,
logger: {
debug: () => undefined,
info: (message) => {
console.info(`[db:migrate] ${message}`)
},
warn: (message) => {
console.warn(`[db:migrate] ${message}`)
},
error: (message) => {
if (SHORT_NUMERIC_PREFIX_MESSAGE.test(message)) {
return
}
console.error(`[db:migrate] ${message}`)
},
},
} satisfies RunnerOption)
await syncLegacyMigrationColumns()
}
function assertMigrationFilesOrdered() {
if (!fs.existsSync(MIGRATIONS_DIR)) {
throw new Error(`迁移目录不存在: ${MIGRATIONS_DIR}`)
}
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((name) => name.endsWith('.sql'))
.sort((a, b) => a.localeCompare(b, 'en'))
if (files.length === 0) {
throw new Error(`迁移目录为空: ${MIGRATIONS_DIR}`)
}
const invalid = files.filter((name) => !MIGRATION_FILE_PATTERN.test(name))
if (invalid.length > 0) {
throw new Error(
`迁移文件命名必须为 NNN_name.sql(三位序号),非法文件: ${invalid.join(', ')}`,
)
}
const versions = files.map((name) => Number(name.slice(0, 3)))
for (let index = 1; index < versions.length; index += 1) {
const current = versions[index]
const previous = versions[index - 1]
const currentFile = files[index]
const previousFile = files[index - 1]
if (current === undefined || previous === undefined || !currentFile || !previousFile) {
continue
}
if (current < previous) {
throw new Error(`迁移序号乱序: ${previousFile} 之后出现 ${currentFile}`)
}
if (current === previous) {
throw new Error(`迁移序号重复: ${previousFile} 与 ${currentFile}`)
}
}
if (files[0] !== '001_init.sql') {
console.warn(
`[db:migrate] 警告: 首个迁移不是 001_init.sql(当前为 ${files[0]}),请确认是否刻意调整基线`,
)
}
}
async function ensureNodePgMigrateMetadataCompatibility() {
await query(`
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
run_on TIMESTAMPTZ,
filename TEXT,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
await query(`
ALTER TABLE ${MIGRATIONS_TABLE}
ADD COLUMN IF NOT EXISTS name VARCHAR(255),
ADD COLUMN IF NOT EXISTS run_on TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS filename TEXT,
ADD COLUMN IF NOT EXISTS applied_at TIMESTAMPTZ
`)
await query(`
ALTER TABLE ${MIGRATIONS_TABLE}
ALTER COLUMN filename DROP NOT NULL,
ALTER COLUMN applied_at SET DEFAULT NOW()
`)
await query(`
UPDATE ${MIGRATIONS_TABLE}
SET
name = COALESCE(
NULLIF(name, ''),
NULLIF(REGEXP_REPLACE(COALESCE(filename, ''), '\\.[^.]+$', ''), ''),
CONCAT('legacy_', id)
),
run_on = COALESCE(run_on, applied_at, NOW())
WHERE name IS NULL
OR name = ''
OR run_on IS NULL
`)
await query(`
ALTER TABLE ${MIGRATIONS_TABLE}
ALTER COLUMN name SET NOT NULL,
ALTER COLUMN run_on SET NOT NULL
`)
await query(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_schema_migrations_name
ON ${MIGRATIONS_TABLE}(name)
`)
}
async function syncLegacyMigrationColumns() {
await query(`
UPDATE ${MIGRATIONS_TABLE}
SET
filename = COALESCE(NULLIF(filename, ''), CONCAT(name, '.sql')),
applied_at = COALESCE(applied_at, run_on, NOW())
WHERE filename IS NULL
OR filename = ''
OR applied_at IS NULL
`)
}
function createMigrationDatabaseConfig(): ClientConfig {
const connectionString = String(runtimeConfig.database?.url || '').trim()
if (!connectionString) {
throw new Error('DATABASE_URL 未配置,无法执行数据库迁移')
}
return {
connectionString,
ssl: runtimeConfig.database?.ssl ? { rejectUnauthorized: false } : false,
}
}
const currentFilePath = fileURLToPath(import.meta.url)
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
runDatabaseMigrations()
.then(async () => {
await closeDb()
})
.catch(async (error) => {
console.error(error)
await closeDb()
process.exitCode = 1
})
}