2026-09-20 18:51:10 +08:00
#!/usr/bin/env node
import { readFile } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
const DEFAULT_SCHEMAS = [
'baiyue_auth' , 'baiyue_iam' , 'baiyue_order' , 'baiyue_warehouse' ,
'baiyue_firstleg' , 'baiyue_file' , 'baiyue_audit' , 'baiyue_event_bridge' ,
'baiyue_finance' , 'baiyue_lastleg' , 'baiyue_lastlag_warehouse' ,
'baiyue_event_bridge_overseas' ,
]
function usage () {
console . log ( '用法:node scripts/database-schema-diff.mjs --left-env <env文件> --right-env <env文件> [--schemas schema1,schema2] [--json]' )
console . log ( 'env 键:CHONGCHONG_ENV、CHONGCHONG_DB_HOST、CHONGCHONG_DB_PORT、CHONGCHONG_DB_USERNAME、CHONGCHONG_DB_PASSWORD' )
console . log ( '只查询 information_schema;不读取业务数据,不执行 DDL/DML。差异退出码为 1,执行错误退出码为 2。' )
}
function argument ( argv , name ) {
const index = argv . indexOf ( name )
return index >= 0 ? argv [ index + 1 ] : undefined
}
function parseArguments ( argv ) {
if ( argv . includes ( '--help' ) || argv . includes ( '-h' )) return { help : true }
const leftEnv = argument ( argv , '--left-env' )
const rightEnv = argument ( argv , '--right-env' )
if ( ! leftEnv || ! rightEnv ) throw new Error ( '必须同时指定 --left-env 和 --right-env' )
const schemasValue = argument ( argv , '--schemas' )
const schemas = schemasValue ? schemasValue . split ( ',' ). map (( item ) => item . trim ()). filter ( Boolean ) : DEFAULT_SCHEMAS
if ( ! schemas . length || schemas . some (( schema ) => ! /^[a-z][a-z0-9_]*$/i . test ( schema ))) throw new Error ( '--schemas 包含无效 schema 名称' )
return { leftEnv : path . resolve ( leftEnv ), rightEnv : path . resolve ( rightEnv ), schemas : [... new Set ( schemas )], json : argv . includes ( '--json' ) }
}
function unquote ( value ) {
const trimmed = value . trim ()
if ( trimmed . length >= 2 && trimmed [ 0 ] === trimmed . at ( - 1 ) && [ '"' , "'" ]. includes ( trimmed [ 0 ])) return trimmed . slice ( 1 , - 1 )
return trimmed
}
async function readConfig ( file ) {
const values = {}
const source = await readFile ( file , 'utf8' )
for ( const line of source . split ( /\r?\n/ )) {
const match = line . match ( /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/ )
if ( match ) values [ match [ 1 ]] = unquote ( match [ 2 ])
}
const required = [ 'CHONGCHONG_DB_HOST' , 'CHONGCHONG_DB_PORT' , 'CHONGCHONG_DB_USERNAME' , 'CHONGCHONG_DB_PASSWORD' ]
const missing = required . filter (( key ) => ! values [ key ])
if ( missing . length ) throw new Error ( ` ${ path . basename ( file ) } 缺少: ${ missing . join ( ', ' ) } ` )
return { ... values , label : values . CHONGCHONG_ENV || path . basename ( file ) }
}
function sqlStrings ( values ) {
return values . map (( value ) => `' ${ value . replaceAll ( "'" , "''" ) } '` ). join ( ',' )
}
2026-09-30 15:16:39 +08:00
export function parseMetadataRows ( output ) {
return output . trim (). split ( /\r?\n/ ). filter ( Boolean ). map (( line ) => {
if ( ! /^(?:[0-9a-f]{2})+$/i . test ( line )) throw new Error ( '数据库元数据输出不是有效 HEX JSON' )
return JSON . parse ( Buffer . from ( line , 'hex' ). toString ( 'utf8' ))
})
}
2026-09-20 18:51:10 +08:00
function runMysql ( config , sql ) {
const executable = process . env . MYSQL_BIN || 'mysql'
const result = spawnSync ( executable , [
'--protocol=TCP' , '-h' , config . CHONGCHONG_DB_HOST , '-P' , config . CHONGCHONG_DB_PORT ,
'-u' , config . CHONGCHONG_DB_USERNAME , '--batch' , '--raw' , '--skip-column-names' ,
'--default-character-set=utf8mb4' , '-e' , sql ,
], {
encoding : 'utf8' ,
env : { ... process . env , MYSQL_PWD : config . CHONGCHONG_DB_PASSWORD },
maxBuffer : 32 * 1024 * 1024 ,
})
if ( result . error ) throw new Error ( ` ${ config . label } mysql 客户端执行失败: ${ result . error . message } ` )
if ( result . status !== 0 ) throw new Error ( ` ${ config . label } 元数据查询失败: ${ ( result . stderr || result . stdout ). trim () } ` )
2026-09-30 15:16:39 +08:00
return parseMetadataRows ( result . stdout )
2026-09-20 18:51:10 +08:00
}
function snapshot ( config , schemas ) {
const selected = sqlStrings ( schemas )
2026-09-30 15:16:39 +08:00
const schemaRows = runMysql ( config , `SELECT HEX(JSON_OBJECT('schema', SCHEMA_NAME)) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN ( ${ selected } ) ORDER BY SCHEMA_NAME` )
const tableRows = runMysql ( config , `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, ''))) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN ( ${ selected } ) ORDER BY TABLE_SCHEMA, TABLE_NAME` )
const columnRows = runMysql ( config , `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION)) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN ( ${ selected } ) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION` )
2026-09-20 18:51:10 +08:00
return { schemas : schemaRows , tables : tableRows , columns : columnRows }
}
function keyed ( rows , keyOf , normalized ) {
return new Map ( rows . map (( row ) => [ keyOf ( row ), normalized ( row )]))
}
function compareMaps ( left , right ) {
const leftOnly = [... left . keys ()]. filter (( key ) => ! right . has ( key )). sort ()
const rightOnly = [... right . keys ()]. filter (( key ) => ! left . has ( key )). sort ()
const changed = [... left . keys ()]. filter (( key ) => right . has ( key ) && JSON . stringify ( left . get ( key )) !== JSON . stringify ( right . get ( key ))). sort ()
. map (( key ) => ({ key , left : left . get ( key ), right : right . get ( key ) }))
return { leftOnly , rightOnly , changed }
}
export function compareSnapshots ( left , right ) {
const schemaKey = ( row ) => row . schema
const tableKey = ( row ) => ` ${ row . schema } . ${ row . table } `
const columnKey = ( row ) => ` ${ row . schema } . ${ row . table } . ${ row . column } `
const schemas = compareMaps ( keyed ( left . schemas , schemaKey , () => true ), keyed ( right . schemas , schemaKey , () => true ))
const tables = compareMaps (
keyed ( left . tables , tableKey , ({ type , engine , collation }) => ({ type , engine , collation })),
keyed ( right . tables , tableKey , ({ type , engine , collation }) => ({ type , engine , collation })),
)
const normalizeColumn = ({ type , nullable , defaultValue , extra , charset , collation , generation }) => (
{ type , nullable , defaultValue , extra , charset , collation , generation }
)
const columns = compareMaps ( keyed ( left . columns , columnKey , normalizeColumn ), keyed ( right . columns , columnKey , normalizeColumn ))
return { schemas , tables , columns }
}
function count ( diff ) {
return diff . leftOnly . length + diff . rightOnly . length + diff . changed . length
}
function printSection ( title , diff , leftLabel , rightLabel ) {
if ( ! count ( diff )) return
console . log ( `\n ${ title } ` )
for ( const key of diff . leftOnly ) console . log ( ` 仅 ${ leftLabel } : ${ key } ` )
for ( const key of diff . rightOnly ) console . log ( ` 仅 ${ rightLabel } : ${ key } ` )
for ( const item of diff . changed ) {
console . log ( ` 定义不同: ${ item . key } ` )
console . log ( ` ${ leftLabel } : ${ JSON . stringify ( item . left ) } ` )
console . log ( ` ${ rightLabel } : ${ JSON . stringify ( item . right ) } ` )
}
}
async function main () {
const options = parseArguments ( process . argv . slice ( 2 ))
if ( options . help ) return usage ()
const [ leftConfig , rightConfig ] = await Promise . all ([ readConfig ( options . leftEnv ), readConfig ( options . rightEnv )])
if ( leftConfig . label === rightConfig . label ) throw new Error ( `两侧环境标签相同: ${ leftConfig . label } ` )
const [ left , right ] = [ snapshot ( leftConfig , options . schemas ), snapshot ( rightConfig , options . schemas )]
const differences = compareSnapshots ( left , right )
const result = {
left : leftConfig . label ,
right : rightConfig . label ,
checkedSchemas : options . schemas ,
summary : {
schemas : count ( differences . schemas ),
tables : count ( differences . tables ),
columns : count ( differences . columns ),
},
differences ,
}
if ( options . json ) console . log ( JSON . stringify ( result , null , 2 ))
else {
console . log ( `数据库结构比较: ${ result . left } <-> ${ result . right } ` )
console . log ( `检查 schema= ${ options . schemas . length } ; schema 差异=${ result . summary . schemas } ,表差异= ${ result . summary . tables } ,字段差异= ${ result . summary . columns } ` )
printSection ( 'SCHEMA 差异' , differences . schemas , result . left , result . right )
printSection ( '表差异' , differences . tables , result . left , result . right )
printSection ( '字段差异' , differences . columns , result . left , result . right )
}
if ( Object . values ( result . summary ). some (( value ) => value > 0 )) process . exitCode = 1
}
const isEntryPoint = process . argv [ 1 ] && path . resolve ( process . argv [ 1 ]) === path . resolve ( new URL ( import . meta . url ). pathname . replace ( /^\/(?:[A-Za-z]:)/ , ( value ) => value . slice ( 1 )))
if ( isEntryPoint ) main (). catch (( error ) => {
console . error ( `DATABASE_SCHEMA_DIFF_ERROR: ${ error . message } ` )
process . exitCode = 2
})