Files
BYW-VoiceHub/scripts/db-sync.js
T

497 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import { config } from 'dotenv'
import postgres from 'postgres'
config({ path: path.resolve(process.cwd(), '.env') })
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
cyan: '\x1b[36m'
}
const log = (msg, color = 'reset') => console.log(`${colors[color]}${msg}${colors.reset}`)
const ok = (msg) => log(`✅ ${msg}`, 'green')
const warn = (msg) => log(`⚠️ ${msg}`, 'yellow')
const err = (msg) => log(`❌ ${msg}`, 'red')
const NON_INTERACTIVE_ENV = {
...process.env,
CI: 'true',
DRIZZLE_KIT_FORCE: 'true',
DRIZZLE_KIT_NON_INTERACTIVE: 'true',
NODE_ENV: process.env.NODE_ENV || 'production'
}
function safeExec(command, options = {}) {
try {
execSync(command, { stdio: 'inherit', ...options })
return true
} catch (e) {
return false
}
}
function fileExists(p) {
try {
return fs.existsSync(p)
} catch {
return false
}
}
function ensureDrizzleFiles() {
if (!fileExists('drizzle.config.ts')) throw new Error('Drizzle 配置文件不存在')
if (!fileExists('app/drizzle/schema.ts')) throw new Error('Schema 文件不存在')
if (!fileExists('app/drizzle/migrations/meta/_journal.json'))
throw new Error('Drizzle journal 文件不存在')
}
function createSqlClient() {
return postgres(process.env.DATABASE_URL, { max: 1 })
}
async function isEmptyDatabase(sql) {
const result = await sql`
SELECT COUNT(*)::int AS count
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name <> '__drizzle_migrations__'
`
return result[0]?.count === 0
}
async function hasMigrationRecords(sql) {
const migrationTable = await sql`
SELECT to_regclass('public.__drizzle_migrations__') AS table_name
`
if (!migrationTable[0]?.table_name) {
return false
}
const result = await sql`
SELECT COUNT(*)::int AS count
FROM public.__drizzle_migrations__
`
return (result[0]?.count || 0) > 0
}
function loadMigrationJournalEntries() {
const journalPath = path.resolve(process.cwd(), 'app/drizzle/migrations/meta/_journal.json')
const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8'))
return [...journal.entries].sort((a, b) => a.when - b.when)
}
async function seedMissingMigrationRecords(sql) {
const entries = loadMigrationJournalEntries()
await sql`CREATE TABLE IF NOT EXISTS public.__drizzle_migrations__ (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at bigint
)`
for (const entry of entries) {
await sql`
INSERT INTO public.__drizzle_migrations__ (hash, created_at)
SELECT ${`legacy:${entry.tag}`}, ${entry.when}
WHERE NOT EXISTS (
SELECT 1
FROM public.__drizzle_migrations__
WHERE created_at = ${entry.when}
)
`
}
}
async function enumExists(sql, enumName) {
const result = await sql`
SELECT EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public'
AND t.typname = ${enumName}
AND t.typtype = 'e'
) AS exists
`
return result[0]?.exists === true
}
async function enumValueExists(sql, enumName, enumValue) {
const result = await sql`
SELECT EXISTS (
SELECT 1
FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE n.nspname = 'public'
AND t.typname = ${enumName}
AND e.enumlabel = ${enumValue}
) AS exists
`
return result[0]?.exists === true
}
// user_status 枚举新增值显式幂等补齐
async function ensureUserStatusEnumValues(sql) {
const pendingExists = await enumValueExists(sql, 'user_status', 'pending')
if (!pendingExists) {
const withdrawnExists = await enumValueExists(sql, 'user_status', 'withdrawn')
if (withdrawnExists) {
await sql`ALTER TYPE "public"."user_status" ADD VALUE 'pending' BEFORE 'withdrawn'`
} else {
await sql`ALTER TYPE "public"."user_status" ADD VALUE 'pending'`
}
}
const rejectedExists = await enumValueExists(sql, 'user_status', 'rejected')
if (!rejectedExists) {
await sql`ALTER TYPE "public"."user_status" ADD VALUE 'rejected'`
}
}
async function tableExists(sql, tableName) {
const result = await sql`
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = ${tableName}
) AS exists
`
return result[0]?.exists === true
}
async function columnExists(sql, tableName, columnName) {
const result = await sql`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = ${tableName}
AND column_name = ${columnName}
) AS exists
`
return result[0]?.exists === true
}
async function indexExists(sql, tableName, indexName) {
const result = await sql`
SELECT EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = ${tableName}
AND indexname = ${indexName}
) AS exists
`
return result[0]?.exists === true
}
// 重复 username 会阻塞 User_username_unique 唯一索引的创建(push/migrate 均会失败)。
// 同步前把多余行重命名为 username_<后缀>,保留最早一行,保证唯一索引可创建。
async function ensureNoDuplicateUsernames(sql) {
if (!(await tableExists(sql, 'User'))) return
const duplicates = await sql`
SELECT username, array_agg(id ORDER BY id) AS ids
FROM "User"
WHERE username IS NOT NULL
GROUP BY username
HAVING COUNT(*) > 1
`
if (duplicates.length === 0) return
warn('检测到 User.username 重复值,重命名多余行以允许创建唯一索引')
for (const dup of duplicates) {
const [keptId, ...extraIds] = dup.ids
for (const id of extraIds) {
let suffix = 2
let newUsername = `${dup.username}_${suffix}`
while ((await sql`SELECT 1 FROM "User" WHERE username = ${newUsername} LIMIT 1`).length > 0) {
suffix += 1
newUsername = `${dup.username}_${suffix}`
}
await sql`UPDATE "User" SET username = ${newUsername} WHERE id = ${id}`
warn(`User#${id}: ${dup.username} -> ${newUsername}(保留最早记录 User#${keptId})`)
}
}
}
// 检查数据库schema是否包含当前代码依赖的关键对象。
async function checkSchemaConsistency(sql) {
const requiredEnums = [
['user_status', ['graduate', 'pending', 'rejected']],
['card_code_status', ['AVAILABLE', 'LOCKED', 'REDEEMED', 'INVALID']]
]
const requiredTables = [
'api_keys',
'api_key_permissions',
'api_logs',
'BackupHistory',
'CardCode',
'CardCodeRedeemLog',
'PasswordAuditLog',
'PasswordRateLimit',
'GradeClass',
'auth_sessions'
]
// 关键唯一索引(legacy 库可能缺失导致并发竞态/迁移失败)
const requiredIndexes = [['User', 'User_username_unique']]
const requiredColumns = {
User: [
'status',
'statusChangedAt',
'statusChangedBy',
'email',
'emailVerified',
'tokenVersion',
'remark',
'avatarProvider',
'avatarProviderUserId'
],
Song: ['playUrl', 'submissionNote', 'submissionNotePublic', 'submissionNotePublicStatus', 'hitRequestId', 'cardCodeId'],
song_replay_requests: ['submission_note', 'submission_note_public', 'submission_note_public_status'],
user_status_logs: ['username', 'name'],
Schedule: ['isDraft', 'publishedAt'],
SystemSettings: [
'instance_id',
'telemetryEnabled',
'smtpEnabled',
'smtpHost',
'smtpPort',
'smtpSecure',
'smtpUsername',
'smtpPassword',
'smtpFromEmail',
'smtpFromName',
'enableRequestTimeLimitation',
'forceBlockAllRequests',
'enableReplayRequests',
'enableCollaborativeSubmission',
'enableSubmissionRemarks',
'enableCardCodeRequests',
'requireCardCodeForRequests',
'enableCardCodeLimitBypass',
'captchaProvider',
'turnstileSiteKey',
'turnstileSecretKey',
'forcePasswordChangeOnFirstLogin',
'allowOAuthRegistration',
'allowRegister',
'registerRequiresApproval',
'oauthRegisterRequiresApproval',
'registerEmailRequired',
'registerRequiresGradeClass',
'submissionNoteRequiresApproval',
'defaultTheme',
'enabledThemes',
'oauthRedirectUri',
'oauthStateSecret',
'oauthProviders',
'githubOAuthEnabled',
'githubClientId',
'githubClientSecret',
'casdoorOAuthEnabled',
'casdoorServerUrl',
'casdoorClientId',
'casdoorClientSecret',
'casdoorOrganizationName',
'googleOAuthEnabled',
'googleClientId',
'googleClientSecret',
'aggregateOAuthEnabled',
'aggregateOAuthAppId',
'aggregateOAuthAppKey',
'aggregateOAuthLoginType',
'aggregateOAuthEndpoint',
'customOAuthEnabled',
'customOAuthDisplayName',
'customOAuthAuthorizeUrl',
'customOAuthTokenUrl',
'customOAuthUserInfoUrl',
'customOAuthScope',
'customOAuthClientId',
'customOAuthClientSecret',
'customOAuthUserIdField',
'customOAuthUsernameField',
'customOAuthNameField',
'customOAuthEmailField',
'customOAuthAvatarField',
'captchaEnabled',
'captchaMaxFailures',
'autoBackupEnabled',
'autoBackupConfig'
],
PasswordAuditLog: [
'userId',
'actorId',
'action',
'success',
'ipAddress',
'userAgent',
'failureReason',
'createdAt'
],
PasswordRateLimit: ['key', 'count', 'resetAt']
}
const missing = []
for (const [enumName, enumValues] of requiredEnums) {
if (!(await enumExists(sql, enumName))) {
missing.push(`${enumName} enum type`)
continue
}
for (const enumValue of enumValues) {
if (!(await enumValueExists(sql, enumName, enumValue))) {
missing.push(`${enumName}.${enumValue} enum value`)
}
}
}
for (const tableName of requiredTables) {
if (!(await tableExists(sql, tableName))) {
missing.push(`${tableName} table`)
}
}
for (const [tableName, columns] of Object.entries(requiredColumns)) {
for (const columnName of columns) {
if (!(await columnExists(sql, tableName, columnName))) {
missing.push(`${tableName}.${columnName} column`)
}
}
}
for (const [tableName, indexName] of requiredIndexes) {
if (!(await indexExists(sql, tableName, indexName))) {
missing.push(`${tableName}.${indexName} index`)
}
}
if (missing.length > 0) {
warn(`检测到数据库schema不完整,缺少: ${missing.join(', ')}`)
return false
}
return true
}
async function repairSchemaWithPush(sql) {
// 先补齐枚举值,再执行 push
await ensureUserStatusEnumValues(sql)
const pushCommand = 'pnpm exec drizzle-kit push --force --config=drizzle.config.ts'
if (
!safeExec(pushCommand, {
env: { ...NON_INTERACTIVE_ENV, DRIZZLE_KIT_NON_INTERACTIVE: 'true' }
})
) {
err('数据库schema修复失败')
return false
}
if (!(await checkSchemaConsistency(sql))) {
err('push 后数据库schema仍不完整')
return false
}
// push 只同步结构,不会写入迁移表;补齐记录可避免后续 migrate 重放已存在的结构。
await seedMissingMigrationRecords(sql)
ok('强制同步完成,迁移记录已补齐')
return true
}
async function main() {
log('🔄 数据库同步', 'cyan')
if (!process.env.DATABASE_URL) {
warn('未设置 DATABASE_URL')
process.exit(0)
}
ensureDrizzleFiles()
const sql = createSqlClient()
try {
const emptyDb = await isEmptyDatabase(sql)
if (emptyDb) {
log('🆕 检测到空库,执行迁移 (migrate)...', 'cyan')
if (!safeExec('pnpm run db:migrate', { env: NON_INTERACTIVE_ENV })) {
err('数据库迁移失败')
process.exit(1)
}
if (!(await checkSchemaConsistency(sql))) {
err('空库迁移后数据库schema仍不完整')
process.exit(1)
}
ok('空库迁移完成')
} else {
// 重复 username 会阻塞唯一索引创建(push/migrate 均失败),先修复数据再同步
await ensureNoDuplicateUsernames(sql)
const migrationRecordsExist = await hasMigrationRecords(sql)
if (migrationRecordsExist) {
// 正常数据库必须先应用待执行迁移,再检查最终结构;否则新增字段会被误判为schema损坏。
log('🔁 检测到迁移记录,先执行 migrate 同步...', 'cyan')
const migrateSuccess = safeExec('pnpm run db:migrate', {
env: { ...NON_INTERACTIVE_ENV, DRIZZLE_KIT_NON_INTERACTIVE: 'true' }
})
const schemaConsistent = migrateSuccess && (await checkSchemaConsistency(sql))
if (migrateSuccess && schemaConsistent) {
ok('migrate 同步成功')
} else {
if (migrateSuccess) {
warn('migrate 已执行,但数据库schema仍不完整。')
} else {
warn('migrate 同步失败,可能是由于数据库结构与迁移记录不一致。')
}
log('🔄 尝试使用 push --force 进行强制同步...', 'cyan')
if (!(await repairSchemaWithPush(sql))) {
err('数据库同步完全失败。请检查数据库连接或迁移文件。')
process.exit(1)
}
}
} else {
warn('检测到 legacy 数据库迁移记录为空,检查schema并写入迁移基线。')
const schemaConsistent = await checkSchemaConsistency(sql)
if (!schemaConsistent) {
log('🔄 legacy schema不完整,尝试使用 push --force 进行同步...', 'cyan')
if (!(await repairSchemaWithPush(sql))) {
process.exit(1)
}
} else {
await seedMissingMigrationRecords(sql)
}
ok('legacy schema同步完成,迁移基线记录已写入')
}
}
} finally {
await sql.end()
}
ok('数据库同步流程完成')
}
main().catch((e) => {
err(`同步异常: ${e.message || e}`)
process.exit(1)
})