fix(data-foundation): 识别已完成迁移链的 no-op 审计状态
This commit is contained in:
parent
40e5f238a4
commit
92af7ea29a
@ -356,6 +356,16 @@ function compareMigrationManifests(expected = [], actual = []) {
|
||||
}
|
||||
}
|
||||
|
||||
function isNoopStructureRepair(structureRehearsal) {
|
||||
return (
|
||||
structureRehearsal?.status === 'structure-repair-rehearsal-passed' &&
|
||||
structureRehearsal?.repairSqlSummary?.hasSqlDiff === false &&
|
||||
structureRehearsal?.repairSqlArtifact == null &&
|
||||
structureRehearsal?.baselineResolvePlan?.alreadyApplied === true &&
|
||||
structureRehearsal?.baselineResolveRehearsal?.ok === true
|
||||
)
|
||||
}
|
||||
|
||||
function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, options) {
|
||||
if (!preflight) {
|
||||
blockers.push({ key: 'preflight.missing', message: '缺少真实施工前 preflight 报告。' })
|
||||
@ -364,6 +374,7 @@ function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, o
|
||||
blockers.push({ key: 'structureRehearsal.missing', message: '缺少克隆库结构修复演练报告。' })
|
||||
return
|
||||
}
|
||||
const noopStructureRepair = isNoopStructureRepair(structureRehearsal)
|
||||
|
||||
if (preflight?.backupEvidence?.usable !== true) {
|
||||
blockers.push({ key: 'backupEvidence', message: '真实施工前必须有可用的当前数据库备份。' })
|
||||
@ -383,7 +394,7 @@ function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, o
|
||||
message: '克隆库结构修复演练未通过,不能生成真实施工授权。',
|
||||
})
|
||||
}
|
||||
if (structureRehearsal.repairApply?.ok !== true) {
|
||||
if (!noopStructureRepair && structureRehearsal.repairApply?.ok !== true) {
|
||||
blockers.push({ key: 'repairApply.notOk', message: '结构修复 SQL 在克隆库未成功执行。' })
|
||||
}
|
||||
if (structureRehearsal.repairSqlSummary?.hasDestructiveStatements === true) {
|
||||
@ -406,13 +417,17 @@ function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, o
|
||||
}
|
||||
|
||||
const baselineCount = structureRehearsal.baselineResolvePlan?.migrationCount ?? null
|
||||
if (baselineCount == null || structureRehearsal.baselineResolvePlan?.candidate !== true) {
|
||||
if (
|
||||
!noopStructureRepair &&
|
||||
(baselineCount == null || structureRehearsal.baselineResolvePlan?.candidate !== true)
|
||||
) {
|
||||
blockers.push({ key: 'baselineResolvePlan.notCandidate', message: '演练报告未给出可 baseline resolve 的迁移清单。' })
|
||||
}
|
||||
if (structureRehearsal.baselineResolveRehearsal?.ok !== true) {
|
||||
blockers.push({ key: 'baselineResolveRehearsal.notOk', message: '克隆库 baseline resolve 演练未通过。' })
|
||||
}
|
||||
if (
|
||||
!noopStructureRepair &&
|
||||
baselineCount != null &&
|
||||
structureRehearsal.baselineResolveRehearsal?.appliedCount != null &&
|
||||
structureRehearsal.baselineResolveRehearsal.appliedCount !== baselineCount
|
||||
@ -422,7 +437,12 @@ function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, o
|
||||
message: 'baseline resolve 演练成功数量和迁移清单数量不一致。',
|
||||
})
|
||||
}
|
||||
if (preflight?.pendingMigrationCount != null && baselineCount != null && preflight.pendingMigrationCount !== baselineCount) {
|
||||
if (
|
||||
!noopStructureRepair &&
|
||||
preflight?.pendingMigrationCount != null &&
|
||||
baselineCount != null &&
|
||||
preflight.pendingMigrationCount !== baselineCount
|
||||
) {
|
||||
blockers.push({
|
||||
key: 'preflight.pendingMigrationCountMismatch',
|
||||
message: `preflight pending migration 数量为 ${preflight.pendingMigrationCount},演练 baseline 数量为 ${baselineCount}。`,
|
||||
@ -430,24 +450,26 @@ function addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, o
|
||||
}
|
||||
|
||||
const artifact = structureRehearsal.repairSqlArtifact || null
|
||||
if (!repairSql) {
|
||||
blockers.push({ key: 'repairSql.missing', message: '缺少待执行结构修复 SQL 文件。' })
|
||||
} else if (!repairSql.exists) {
|
||||
blockers.push({ key: 'repairSql.notFound', message: '待执行结构修复 SQL 文件不存在。' })
|
||||
} else if (repairSql.safety?.hasDestructiveStatements === true) {
|
||||
blockers.push({
|
||||
key: 'repairSql.fileDestructive',
|
||||
message: '待执行结构修复 SQL 文件本体含 DROP/清表类破坏性语句,禁止真实施工。',
|
||||
})
|
||||
} else if (artifact) {
|
||||
if (artifact.sizeBytes != null && repairSql.sizeBytes !== artifact.sizeBytes) {
|
||||
blockers.push({ key: 'repairSql.sizeMismatch', message: '待执行 SQL 文件大小和演练 artifact 不一致。' })
|
||||
}
|
||||
if (artifact.sha256 && repairSql.sha256 !== artifact.sha256) {
|
||||
blockers.push({ key: 'repairSql.sha256Mismatch', message: '待执行 SQL 文件 sha256 和演练 artifact 不一致。' })
|
||||
if (!noopStructureRepair) {
|
||||
if (!repairSql) {
|
||||
blockers.push({ key: 'repairSql.missing', message: '缺少待执行结构修复 SQL 文件。' })
|
||||
} else if (!repairSql.exists) {
|
||||
blockers.push({ key: 'repairSql.notFound', message: '待执行结构修复 SQL 文件不存在。' })
|
||||
} else if (repairSql.safety?.hasDestructiveStatements === true) {
|
||||
blockers.push({
|
||||
key: 'repairSql.fileDestructive',
|
||||
message: '待执行结构修复 SQL 文件本体含 DROP/清表类破坏性语句,禁止真实施工。',
|
||||
})
|
||||
} else if (artifact) {
|
||||
if (artifact.sizeBytes != null && repairSql.sizeBytes !== artifact.sizeBytes) {
|
||||
blockers.push({ key: 'repairSql.sizeMismatch', message: '待执行 SQL 文件大小和演练 artifact 不一致。' })
|
||||
}
|
||||
if (artifact.sha256 && repairSql.sha256 !== artifact.sha256) {
|
||||
blockers.push({ key: 'repairSql.sha256Mismatch', message: '待执行 SQL 文件 sha256 和演练 artifact 不一致。' })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.expectedSha256 && repairSql?.sha256 !== options.expectedSha256) {
|
||||
if (!noopStructureRepair && options.expectedSha256 && repairSql?.sha256 !== options.expectedSha256) {
|
||||
blockers.push({ key: 'repairSql.expectedSha256Mismatch', message: '待执行 SQL 文件 sha256 和命令行指定值不一致。' })
|
||||
}
|
||||
}
|
||||
@ -656,13 +678,14 @@ function buildExactPhaseCommands({
|
||||
const structureRehearsalPath = options.structureRehearsalPath || ''
|
||||
const repairSqlPath = options.repairSqlPath || structureRehearsal?.repairSqlArtifact?.path || ''
|
||||
const missing = []
|
||||
const noopStructureRepair = isNoopStructureRepair(structureRehearsal)
|
||||
|
||||
if (!repairSqlSha256) missing.push('repairSqlSha256')
|
||||
if (!noopStructureRepair && !repairSqlSha256) missing.push('repairSqlSha256')
|
||||
if (!databaseIdentityValue) missing.push('databaseIdentity')
|
||||
if (!backupSha256) missing.push('backupSha256')
|
||||
if (!preflightPath) missing.push('preflightPath')
|
||||
if (!structureRehearsalPath) missing.push('structureRehearsalPath')
|
||||
if (!repairSqlPath) missing.push('repairSqlPath')
|
||||
if (!noopStructureRepair && !repairSqlPath) missing.push('repairSqlPath')
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
@ -674,6 +697,17 @@ function buildExactPhaseCommands({
|
||||
}
|
||||
}
|
||||
|
||||
if (noopStructureRepair) {
|
||||
return {
|
||||
available: true,
|
||||
missing: [],
|
||||
noopStructureRepair: true,
|
||||
applyStructureDryRun: null,
|
||||
applyStructure: null,
|
||||
resolveBaselineTemplate: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
missing: [],
|
||||
@ -741,6 +775,7 @@ function buildAuthorizedStructureRepairPlan({
|
||||
checkedAt = new Date().toISOString(),
|
||||
}) {
|
||||
const blockers = []
|
||||
const noopStructureRepair = isNoopStructureRepair(structureRehearsal)
|
||||
addCommonBlockers(blockers, preflight, structureRehearsal, repairSql, options)
|
||||
addPhaseBlockers(blockers, preflight, structureRehearsal, options, {
|
||||
actualDatabase,
|
||||
@ -761,6 +796,7 @@ function buildAuthorizedStructureRepairPlan({
|
||||
phase: options.phase,
|
||||
dryRun: !options.apply,
|
||||
readyToApply,
|
||||
noopStructureRepair,
|
||||
confirmToken: CONFIRM_TOKEN,
|
||||
checkedAt,
|
||||
database: preflight?.database || null,
|
||||
|
||||
@ -191,6 +191,28 @@ function structureRehearsalProvesMigrationSchemaEquivalence(summary) {
|
||||
)
|
||||
}
|
||||
|
||||
function noActionRequired(preflight, evidence) {
|
||||
const structure = evidence.structureRepairRehearsal
|
||||
const structureNoopOrAbsent =
|
||||
!structure.provided ||
|
||||
(
|
||||
structure.passed &&
|
||||
structure.baselineResolveCandidate === false &&
|
||||
Number(structure.baselineResolveMigrationCount ?? 0) === 0 &&
|
||||
structure.currentSchemaDiffAfterStatus === 0 &&
|
||||
structure.migrationDiffAfterStatus === 0 &&
|
||||
structure.prismaMigrateStatusAfterResolveStatus === 0
|
||||
)
|
||||
return Boolean(
|
||||
preflight.recommendedAction === 'none' &&
|
||||
Number(evidence.pendingMigrationCount ?? 0) === 0 &&
|
||||
evidence.migrationSchemaDiffStatus === 'empty' &&
|
||||
evidence.migrationDiffStatus === 'empty' &&
|
||||
evidence.currentSchemaDiffHasSql === false &&
|
||||
structureNoopOrAbsent,
|
||||
)
|
||||
}
|
||||
|
||||
function buildEvidenceSummary(preflight, rehearsal = null, structureRehearsal = null) {
|
||||
const migrationSchemaDiffStatus = diffStatus(preflight.migrationSchemaDiff)
|
||||
const migrationDiffStatus = diffStatus(preflight.migrationDiff)
|
||||
@ -275,6 +297,7 @@ function addEvidenceBlockers(blockers, preflight, evidence) {
|
||||
const rehearsedBaselineCount =
|
||||
evidence.structureRepairRehearsal.baselineResolveMigrationCount
|
||||
if (
|
||||
!noActionRequired(preflight, evidence) &&
|
||||
evidence.structureRepairRehearsal.passed &&
|
||||
rehearsedBaselineCount != null &&
|
||||
evidence.pendingMigrationCount !== rehearsedBaselineCount
|
||||
@ -289,6 +312,7 @@ function addEvidenceBlockers(blockers, preflight, evidence) {
|
||||
}
|
||||
|
||||
if (
|
||||
!noActionRequired(preflight, evidence) &&
|
||||
evidence.structureRepairRehearsal.passed &&
|
||||
evidence.structureRepairRehearsal.repairSqlArtifact == null
|
||||
) {
|
||||
@ -348,8 +372,10 @@ function buildPhasePlan(preflight, evidence, status) {
|
||||
{
|
||||
id: 'operator-runbook',
|
||||
label: '人工施工口径',
|
||||
status: status.startsWith('ready-for-') ? 'ready' : 'blocked',
|
||||
message: status.startsWith('ready-for-')
|
||||
status: status.startsWith('ready-for-') || status === 'no-action-required' ? 'ready' : 'blocked',
|
||||
message: status === 'no-action-required'
|
||||
? '当前结构和迁移历史已闭合,不需要施工。'
|
||||
: status.startsWith('ready-for-')
|
||||
? '已形成可执行下一步。'
|
||||
: '仍需先处理阻断项,再允许真实写库。',
|
||||
},
|
||||
@ -357,6 +383,19 @@ function buildPhasePlan(preflight, evidence, status) {
|
||||
}
|
||||
|
||||
function buildRunbook(preflight, evidence, status) {
|
||||
if (status === 'no-action-required') {
|
||||
return {
|
||||
summary: '当前库结构、迁移历史和迁移链证据已经闭合,不需要结构修复、baseline resolve 或 migrate deploy。',
|
||||
commands: [
|
||||
command(
|
||||
'readiness',
|
||||
'npm run db:verify-master-data:readiness',
|
||||
'保持只读 readiness 作为后续生产巡检入口。',
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'ready-for-migrate-deploy') {
|
||||
return {
|
||||
summary: '当前可进入标准 Prisma migrate deploy 流程,但仍必须在真实执行前重新跑 preflight。',
|
||||
@ -485,6 +524,7 @@ function buildRunbook(preflight, evidence, status) {
|
||||
|
||||
function decideStatus(preflight, evidence, blockers) {
|
||||
if (blockers.length > 0) return 'evidence-blocked'
|
||||
if (noActionRequired(preflight, evidence)) return 'no-action-required'
|
||||
if (preflight.recommendedAction === 'migrate-deploy-required') return 'ready-for-migrate-deploy'
|
||||
if (
|
||||
preflight.recommendedAction === 'baseline-resolve-candidate' &&
|
||||
@ -576,7 +616,7 @@ async function main() {
|
||||
writeJsonReport(options.outputPath, plan)
|
||||
console.log(JSON.stringify(plan, null, 2))
|
||||
|
||||
if (!plan.status.startsWith('ready-for-')) {
|
||||
if (!plan.status.startsWith('ready-for-') && plan.status !== 'no-action-required') {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@ -595,6 +635,7 @@ module.exports = {
|
||||
buildRunbook,
|
||||
decideStatus,
|
||||
diffStatus,
|
||||
noActionRequired,
|
||||
parseArgs,
|
||||
readJsonFile,
|
||||
structureRehearsalProvesMigrationSchemaEquivalence,
|
||||
|
||||
@ -207,10 +207,11 @@ function buildMigrationManifest(rootDir = ROOT_DIR) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildBaselineResolvePlan(migrationIds, structureAligned) {
|
||||
function buildBaselineResolvePlan(migrationIds, structureAligned, options = {}) {
|
||||
if (!structureAligned) {
|
||||
return {
|
||||
candidate: false,
|
||||
alreadyApplied: false,
|
||||
migrationCount: 0,
|
||||
migrationIds: [],
|
||||
commandsPreview: [],
|
||||
@ -218,8 +219,20 @@ function buildBaselineResolvePlan(migrationIds, structureAligned) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.alreadyApplied) {
|
||||
return {
|
||||
candidate: false,
|
||||
alreadyApplied: true,
|
||||
migrationCount: 0,
|
||||
migrationIds: [],
|
||||
commandsPreview: [],
|
||||
warning: '克隆库已包含完整 Prisma 迁移历史,不需要执行 baseline resolve。',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
candidate: true,
|
||||
alreadyApplied: false,
|
||||
migrationCount: migrationIds.length,
|
||||
migrationIds,
|
||||
commandsPreview: migrationIds.slice(0, 12).map((migrationId) =>
|
||||
@ -229,14 +242,29 @@ function buildBaselineResolvePlan(migrationIds, structureAligned) {
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeResolveResults(results) {
|
||||
function summarizeResolveResults(results, options = {}) {
|
||||
if (options.skippedAlreadyApplied) {
|
||||
return {
|
||||
attempted: false,
|
||||
attemptedCount: 0,
|
||||
appliedCount: 0,
|
||||
skippedCount: Number(options.migrationCount ?? 0),
|
||||
failedCount: 0,
|
||||
ok: true,
|
||||
skipReason: 'already-applied',
|
||||
firstFailed: null,
|
||||
}
|
||||
}
|
||||
|
||||
const firstFailed = results.find((result) => !result.ok) || null
|
||||
return {
|
||||
attempted: results.length > 0,
|
||||
attemptedCount: results.length,
|
||||
appliedCount: results.filter((result) => result.ok).length,
|
||||
skippedCount: results.filter((result) => result.skipped).length,
|
||||
failedCount: results.filter((result) => !result.ok).length,
|
||||
ok: results.length > 0 && results.every((result) => result.ok),
|
||||
skipReason: '',
|
||||
firstFailed: firstFailed
|
||||
? {
|
||||
migrationId: firstFailed.migrationId,
|
||||
@ -327,6 +355,7 @@ function buildReport({
|
||||
readinessAfterResolve = null,
|
||||
requiredMigrationApplyAfterResolve = null,
|
||||
readinessAfterRequiredMigrations = null,
|
||||
baselineResolveAlreadyApplied = false,
|
||||
cleanupStatus,
|
||||
checkedAt = new Date().toISOString(),
|
||||
}) {
|
||||
@ -353,7 +382,10 @@ function buildReport({
|
||||
blockers.push({ key: 'migrationDiffAfter.notEmpty', message: '结构修复后克隆库仍未对齐 migrations 重放结果。' })
|
||||
}
|
||||
const structureAligned = currentSchemaDiffAfter?.status === 0 && migrationDiffAfter?.status === 0
|
||||
const baselineResolveRehearsal = summarizeResolveResults(baselineResolveResults)
|
||||
const baselineResolveRehearsal = summarizeResolveResults(baselineResolveResults, {
|
||||
skippedAlreadyApplied: baselineResolveAlreadyApplied,
|
||||
migrationCount: migrationIds.length,
|
||||
})
|
||||
if (structureAligned && !baselineResolveRehearsal.ok) {
|
||||
blockers.push({
|
||||
key: 'baselineResolveRehearsal.failed',
|
||||
@ -474,7 +506,9 @@ function buildReport({
|
||||
error: migrationDiffAfter.error,
|
||||
}
|
||||
: null,
|
||||
baselineResolvePlan: buildBaselineResolvePlan(migrationIds, structureAligned),
|
||||
baselineResolvePlan: buildBaselineResolvePlan(migrationIds, structureAligned, {
|
||||
alreadyApplied: baselineResolveAlreadyApplied,
|
||||
}),
|
||||
baselineResolveRehearsal,
|
||||
prismaMigrateStatusAfterResolve: prismaMigrateStatusAfterResolve
|
||||
? {
|
||||
@ -533,6 +567,7 @@ async function runStructureRepairRehearsal(options = parseArgs(), env = process.
|
||||
let readinessAfterResolve = null
|
||||
let requiredMigrationApplyAfterResolve = null
|
||||
let readinessAfterRequiredMigrations = null
|
||||
let baselineResolveAlreadyApplied = false
|
||||
const migrationIds = listMigrationDirectories()
|
||||
const migrationManifest = buildMigrationManifest()
|
||||
|
||||
@ -680,34 +715,63 @@ async function runStructureRepairRehearsal(options = parseArgs(), env = process.
|
||||
},
|
||||
})
|
||||
if (currentSchemaDiffAfter.status === 0 && migrationDiffAfter.status === 0) {
|
||||
baselineResolveResults = migrationIds.map((migrationId) => ({
|
||||
migrationId,
|
||||
...runCommand(process.execPath, [
|
||||
PRISMA_CLI,
|
||||
'migrate',
|
||||
'resolve',
|
||||
'--applied',
|
||||
const prismaMigrateStatusBeforeResolve = runCommand(process.execPath, [
|
||||
PRISMA_CLI,
|
||||
'migrate',
|
||||
'status',
|
||||
], {
|
||||
env: {
|
||||
...env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
},
|
||||
})
|
||||
|
||||
if (prismaMigrateStatusBeforeResolve.status === 0) {
|
||||
baselineResolveAlreadyApplied = true
|
||||
prismaMigrateStatusAfterResolve = prismaMigrateStatusBeforeResolve
|
||||
} else {
|
||||
baselineResolveResults = migrationIds.map((migrationId) => ({
|
||||
migrationId,
|
||||
...runCommand(process.execPath, [
|
||||
PRISMA_CLI,
|
||||
'migrate',
|
||||
'resolve',
|
||||
'--applied',
|
||||
migrationId,
|
||||
], {
|
||||
env: {
|
||||
...env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
if (baselineResolveResults.every((result) => result.ok)) {
|
||||
prismaMigrateStatusAfterResolve = runCommand(process.execPath, [
|
||||
PRISMA_CLI,
|
||||
'migrate',
|
||||
'status',
|
||||
], {
|
||||
env: {
|
||||
...env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (prismaMigrateStatusAfterResolve?.status === 0) {
|
||||
readinessAfterResolve = runCommand(process.execPath, [
|
||||
'scripts/verify-master-data-readiness.js',
|
||||
], {
|
||||
env: {
|
||||
...env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
if (baselineResolveResults.every((result) => result.ok)) {
|
||||
prismaMigrateStatusAfterResolve = runCommand(process.execPath, [
|
||||
PRISMA_CLI,
|
||||
'migrate',
|
||||
'status',
|
||||
], {
|
||||
env: {
|
||||
...env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
SHADOW_DATABASE_URL: shadowDatabaseUrl,
|
||||
},
|
||||
})
|
||||
if (prismaMigrateStatusAfterResolve.status === 0) {
|
||||
readinessAfterResolve = runCommand(process.execPath, [
|
||||
requiredMigrationApplyAfterResolve = await applyRequiredMigrationsToClone(databaseUrl)
|
||||
if (requiredMigrationApplyAfterResolve.ok) {
|
||||
readinessAfterRequiredMigrations = runCommand(process.execPath, [
|
||||
'scripts/verify-master-data-readiness.js',
|
||||
], {
|
||||
env: {
|
||||
@ -716,18 +780,6 @@ async function runStructureRepairRehearsal(options = parseArgs(), env = process.
|
||||
SHADOW_DATABASE_URL: shadowDatabaseUrl,
|
||||
},
|
||||
})
|
||||
requiredMigrationApplyAfterResolve = await applyRequiredMigrationsToClone(databaseUrl)
|
||||
if (requiredMigrationApplyAfterResolve.ok) {
|
||||
readinessAfterRequiredMigrations = runCommand(process.execPath, [
|
||||
'scripts/verify-master-data-readiness.js',
|
||||
], {
|
||||
env: {
|
||||
...env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
SHADOW_DATABASE_URL: shadowDatabaseUrl,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -766,6 +818,7 @@ async function runStructureRepairRehearsal(options = parseArgs(), env = process.
|
||||
readinessAfterResolve,
|
||||
requiredMigrationApplyAfterResolve,
|
||||
readinessAfterRequiredMigrations,
|
||||
baselineResolveAlreadyApplied,
|
||||
cleanupStatus,
|
||||
})
|
||||
}
|
||||
|
||||
@ -105,7 +105,13 @@ function writeJsonReport(outputPath, payload, rootDir = ROOT_DIR) {
|
||||
function buildFoundationMigrationPlan(preflight, options = {}) {
|
||||
const blockers = [...(preflight.blockers ?? [])]
|
||||
const canDeploy = preflight.ready && preflight.recommendedAction === 'migrate-deploy-required'
|
||||
if (preflight.ready && preflight.recommendedAction !== 'migrate-deploy-required') {
|
||||
const noop = Boolean(
|
||||
preflight.ready &&
|
||||
preflight.recommendedAction === 'none' &&
|
||||
Number(preflight.pendingMigrationCount ?? 0) === 0 &&
|
||||
preflight.currentSchemaDiffHasSql !== true,
|
||||
)
|
||||
if (preflight.ready && preflight.recommendedAction !== 'migrate-deploy-required' && !noop) {
|
||||
blockers.push({
|
||||
key: 'recommendedAction',
|
||||
message: `当前建议动作是 ${preflight.recommendedAction},本脚本只执行 migrate deploy。`,
|
||||
@ -161,7 +167,9 @@ function buildFoundationMigrationPlan(preflight, options = {}) {
|
||||
return {
|
||||
mode: 'database-foundation-migration-plan',
|
||||
dryRun: !options.apply,
|
||||
noop,
|
||||
readyToRun: canDeploy && blockers.length === 0,
|
||||
executionState: noop ? 'no-action-required' : options.apply ? 'apply-requested' : 'plan-only',
|
||||
recommendedAction: preflight.recommendedAction,
|
||||
database: preflight.database,
|
||||
backupEvidence: preflight.backupEvidence,
|
||||
@ -211,7 +219,7 @@ async function main() {
|
||||
writeJsonReport(options.outputPath, plan)
|
||||
console.log(JSON.stringify(plan, null, 2))
|
||||
|
||||
if (!plan.readyToRun) {
|
||||
if (!plan.readyToRun && !plan.noop) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
||||
@ -1137,6 +1137,7 @@ function buildAuthorizedStructureRepairApplyPlanSummary(applyPlan) {
|
||||
phase: applyPlan.phase || '',
|
||||
dryRun,
|
||||
readyToApply,
|
||||
noopStructureRepair: applyPlan.noopStructureRepair === true,
|
||||
requiresExplicitAuthorization: dryRun || !readyToApply,
|
||||
executionState: readyToApply
|
||||
? 'ready-to-apply-with-explicit-authorization'
|
||||
@ -1166,6 +1167,7 @@ function buildAuthorizedStructureRepairApplyPlanSummary(applyPlan) {
|
||||
exactPhaseCommandAvailability: exactPhaseCommands
|
||||
? {
|
||||
available: exactPhaseCommands.available === true,
|
||||
noopStructureRepair: exactPhaseCommands.noopStructureRepair === true,
|
||||
missing: Array.isArray(exactPhaseCommands.missing)
|
||||
? exactPhaseCommands.missing
|
||||
: [],
|
||||
@ -1268,6 +1270,7 @@ function buildFoundationCompletionChecklist({
|
||||
}) {
|
||||
const stepOk = (id) => stepResults.find((result) => result.id === id)?.ok === true
|
||||
const stepMissing = (id) => !stepResults.some((result) => result.id === id)
|
||||
const migrationPlan = stepResults.find((result) => result.id === 'foundation-migrate-plan')?.data ?? null
|
||||
const schemaBlockerKeys = readinessBlockerSourceKeys(readiness, (blocker) =>
|
||||
[
|
||||
'prisma-migrate-status',
|
||||
@ -1317,6 +1320,19 @@ function buildFoundationCompletionChecklist({
|
||||
const authorizedRepairSummary = buildAuthorizedStructureRepairApplyPlanSummary(
|
||||
authorizedStructureRepairApplyPlan,
|
||||
)
|
||||
const foundationMigratePlanPendingCount = Number(
|
||||
migrationPlan?.pendingMigrationCount ??
|
||||
(Array.isArray(migrationPlan?.pendingMigrations)
|
||||
? migrationPlan.pendingMigrations.length
|
||||
: 0),
|
||||
)
|
||||
const foundationMigratePlanNoop =
|
||||
migrationPlan?.recommendedAction === 'none' &&
|
||||
foundationMigratePlanPendingCount === 0 &&
|
||||
readiness?.hardReady === true &&
|
||||
stepOk('foundation-preflight')
|
||||
const foundationMigratePlanSatisfied =
|
||||
stepOk('foundation-migrate-plan') || foundationMigratePlanNoop
|
||||
|
||||
const items = [
|
||||
buildChecklistItem(
|
||||
@ -1324,7 +1340,7 @@ function buildFoundationCompletionChecklist({
|
||||
'数据库结构与 Prisma 迁移历史',
|
||||
schemaBlockerKeys.length === 0 &&
|
||||
stepOk('foundation-preflight') &&
|
||||
stepOk('foundation-migrate-plan') &&
|
||||
foundationMigratePlanSatisfied &&
|
||||
stepOk('foundation-required-migration-apply-plan'),
|
||||
{
|
||||
missing: stepMissing('readiness') || stepMissing('foundation-preflight'),
|
||||
@ -1333,6 +1349,8 @@ function buildFoundationCompletionChecklist({
|
||||
readinessHardReady: readiness?.hardReady === true,
|
||||
foundationPreflightOk: stepOk('foundation-preflight'),
|
||||
foundationMigratePlanOk: stepOk('foundation-migrate-plan'),
|
||||
foundationMigratePlanNoop,
|
||||
foundationMigratePlanPendingCount,
|
||||
foundationRequiredMigrationApplyPlanOk:
|
||||
stepOk('foundation-required-migration-apply-plan'),
|
||||
},
|
||||
|
||||
@ -293,6 +293,63 @@ describe('apply-prisma-structure-repair', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('builds a no-op structure repair plan when migration history is already applied', () => {
|
||||
const plan = buildAuthorizedStructureRepairPlan({
|
||||
preflight: preflight({
|
||||
pendingMigrationCount: 0,
|
||||
currentSchemaDiffHasSql: false,
|
||||
backupEvidence: {
|
||||
usable: true,
|
||||
sha256: backupSha256,
|
||||
},
|
||||
migrationDiff: {
|
||||
skipped: false,
|
||||
status: 0,
|
||||
stdout: '-- This is an empty migration.',
|
||||
},
|
||||
}),
|
||||
structureRehearsal: structureRehearsal({
|
||||
repairApply: null,
|
||||
repairSqlSummary: {
|
||||
hasSqlDiff: false,
|
||||
hasDestructiveStatements: false,
|
||||
},
|
||||
repairSqlArtifact: null,
|
||||
baselineResolvePlan: {
|
||||
candidate: false,
|
||||
alreadyApplied: true,
|
||||
migrationCount: 0,
|
||||
migrationIds: [],
|
||||
},
|
||||
baselineResolveRehearsal: {
|
||||
attempted: false,
|
||||
skippedCount: 86,
|
||||
failedCount: 0,
|
||||
ok: true,
|
||||
skipReason: 'already-applied',
|
||||
},
|
||||
}),
|
||||
repairSql: null,
|
||||
options: options({
|
||||
preflightPath: '.master-data-audits/preflight.json',
|
||||
structureRehearsalPath: '.master-data-audits/structure-repair.json',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(plan.noopStructureRepair).toBe(true)
|
||||
expect(plan.blockers).toEqual([])
|
||||
expect(plan.repairSqlArtifact).toBeNull()
|
||||
expect(plan.expectedSha256).toBe('')
|
||||
expect(plan.exactPhaseCommands).toEqual({
|
||||
available: true,
|
||||
missing: [],
|
||||
noopStructureRepair: true,
|
||||
applyStructureDryRun: null,
|
||||
applyStructure: null,
|
||||
resolveBaselineTemplate: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('emits exact apply-structure args with fingerprints when evidence paths are known', () => {
|
||||
const sqlSha256 = crypto.createHash('sha256').update('CREATE TABLE "A"();\n').digest('hex')
|
||||
const plan = buildAuthorizedStructureRepairPlan({
|
||||
|
||||
@ -217,6 +217,72 @@ describe('plan-prisma-baseline-repair', () => {
|
||||
expect(JSON.stringify(plan)).not.toContain('sensitive/local/structure-repair.sql')
|
||||
})
|
||||
|
||||
it('treats fully current schema and migration history as no-action-required', () => {
|
||||
const plan = buildBaselineRepairPlan({
|
||||
preflight: preflight({
|
||||
recommendedAction: 'none',
|
||||
pendingMigrationCount: 0,
|
||||
currentSchemaDiffHasSql: false,
|
||||
diagnosisBlockers: [],
|
||||
migrationDiff: {
|
||||
skipped: false,
|
||||
ok: true,
|
||||
status: 0,
|
||||
},
|
||||
migrationSchemaDiff: {
|
||||
skipped: false,
|
||||
ok: true,
|
||||
status: 0,
|
||||
},
|
||||
}),
|
||||
structureRehearsal: {
|
||||
status: 'structure-repair-rehearsal-passed',
|
||||
repairApply: null,
|
||||
repairSqlSummary: {
|
||||
hasSqlDiff: false,
|
||||
hasDestructiveStatements: false,
|
||||
},
|
||||
repairSqlArtifact: null,
|
||||
currentSchemaDiffAfter: { status: 0 },
|
||||
migrationDiffAfter: { status: 0 },
|
||||
baselineResolvePlan: {
|
||||
candidate: false,
|
||||
alreadyApplied: true,
|
||||
migrationCount: 0,
|
||||
},
|
||||
baselineResolveRehearsal: {
|
||||
ok: true,
|
||||
attempted: false,
|
||||
skippedCount: 97,
|
||||
skipReason: 'already-applied',
|
||||
},
|
||||
prismaMigrateStatusAfterResolve: { status: 0 },
|
||||
requiredMigrationApplyAfterResolve: {
|
||||
ok: true,
|
||||
appliedMigrationCount: 0,
|
||||
},
|
||||
readinessAfterRequiredMigrations: {
|
||||
hardReady: true,
|
||||
blockerCount: 0,
|
||||
blockerKeys: [],
|
||||
},
|
||||
},
|
||||
checkedAt: '2026-06-01T10:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(plan.status).toBe('no-action-required')
|
||||
expect(plan.blockers).toEqual([])
|
||||
expect(plan.runbook.summary).toContain('不需要结构修复')
|
||||
expect(plan.phasePlan).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'operator-runbook',
|
||||
status: 'ready',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps current diff evidence distinct from post-repair equivalence evidence', () => {
|
||||
const plan = buildBaselineRepairPlan({
|
||||
preflight: preflight({
|
||||
|
||||
@ -228,6 +228,66 @@ describe('rehearse-prisma-structure-repair', () => {
|
||||
expect(report.blockers).toEqual([])
|
||||
})
|
||||
|
||||
it('passes no-op rehearsal when clone migration history is already applied', () => {
|
||||
const report = buildReport({
|
||||
options: options(),
|
||||
containerName: 'container',
|
||||
databaseUrl: 'postgresql://postgres:pass@localhost:65448/furniture_3d_structure_repair?schema=public',
|
||||
restore: commandResult(),
|
||||
currentSchemaDiffBefore: commandResult({ stdout: '-- This is an empty migration.' }),
|
||||
repairSqlSummary: summarizeRepairSql('-- This is an empty migration.'),
|
||||
repairApply: null,
|
||||
currentSchemaDiffAfter: commandResult({ stdout: '-- This is an empty migration.' }),
|
||||
migrationDiffAfter: commandResult({ stdout: '-- This is an empty migration.' }),
|
||||
migrationIds: ['20260101000000_init', '20260102000000_next'],
|
||||
baselineResolveResults: [],
|
||||
baselineResolveAlreadyApplied: true,
|
||||
prismaMigrateStatusAfterResolve: commandResult({ stdout: 'Database schema is up to date!' }),
|
||||
readinessAfterResolve: commandResult({
|
||||
stdout: JSON.stringify({
|
||||
status: 'hard-ready',
|
||||
hardReady: true,
|
||||
blockerCount: 0,
|
||||
blockers: [],
|
||||
}),
|
||||
}),
|
||||
requiredMigrationApplyAfterResolve: {
|
||||
attempted: true,
|
||||
ok: true,
|
||||
appliedMigrationCount: 0,
|
||||
pendingStructureMigrationCount: 0,
|
||||
replayableIdempotentMigrationCount: 0,
|
||||
appliedMigrationPaths: [],
|
||||
},
|
||||
readinessAfterRequiredMigrations: commandResult({
|
||||
stdout: JSON.stringify({
|
||||
status: 'hard-ready',
|
||||
hardReady: true,
|
||||
blockerCount: 0,
|
||||
blockers: [],
|
||||
}),
|
||||
}),
|
||||
cleanupStatus: 'removed',
|
||||
})
|
||||
|
||||
expect(report.status).toBe('structure-repair-rehearsal-passed')
|
||||
expect(report.repairSqlArtifact).toBeNull()
|
||||
expect(report.baselineResolvePlan).toEqual(expect.objectContaining({
|
||||
candidate: false,
|
||||
alreadyApplied: true,
|
||||
migrationCount: 0,
|
||||
migrationIds: [],
|
||||
}))
|
||||
expect(report.baselineResolveRehearsal).toEqual(expect.objectContaining({
|
||||
attempted: false,
|
||||
skippedCount: 2,
|
||||
failedCount: 0,
|
||||
ok: true,
|
||||
skipReason: 'already-applied',
|
||||
}))
|
||||
expect(report.blockers).toEqual([])
|
||||
})
|
||||
|
||||
it('requires readable readiness evidence after baseline resolve succeeds', () => {
|
||||
const report = buildReport({
|
||||
options: options(),
|
||||
@ -352,8 +412,10 @@ describe('rehearse-prisma-structure-repair', () => {
|
||||
attempted: false,
|
||||
attemptedCount: 0,
|
||||
appliedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
ok: false,
|
||||
skipReason: '',
|
||||
firstFailed: null,
|
||||
})
|
||||
expect(summarizeResolveResults([
|
||||
|
||||
@ -111,6 +111,19 @@ describe('run-database-foundation-migrations', () => {
|
||||
expect(plan.blockers).toEqual([])
|
||||
})
|
||||
|
||||
it('treats fully current databases as no-op instead of blocked deploy plans', () => {
|
||||
const plan = buildFoundationMigrationPlan(preflight({
|
||||
recommendedAction: 'none',
|
||||
pendingMigrationCount: 0,
|
||||
currentSchemaDiffHasSql: false,
|
||||
}))
|
||||
|
||||
expect(plan.noop).toBe(true)
|
||||
expect(plan.readyToRun).toBe(false)
|
||||
expect(plan.executionState).toBe('no-action-required')
|
||||
expect(plan.blockers).toEqual([])
|
||||
})
|
||||
|
||||
it('blocks apply without explicit confirmation', () => {
|
||||
const plan = buildFoundationMigrationPlan(preflight(), {
|
||||
apply: true,
|
||||
|
||||
@ -736,6 +736,115 @@ describe('run-master-data-foundation-audit', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a no-op foundation migrate plan when schema and migration history are already current', () => {
|
||||
const cleanStepResults = [
|
||||
stepResult('readiness', true, { hardReady: true, blockers: [] }),
|
||||
stepResult('foundation-preflight', true, { ready: true, blockers: [] }),
|
||||
stepResult('foundation-migrate-plan', false, {
|
||||
readyToRun: false,
|
||||
recommendedAction: 'none',
|
||||
pendingMigrationCount: 0,
|
||||
blockers: [],
|
||||
}),
|
||||
stepResult('foundation-required-migration-apply-plan', true, {
|
||||
blockerKeys: [],
|
||||
}),
|
||||
stepResult('truth-sources', true, {
|
||||
truthSourceCount: 27,
|
||||
prismaModelCount: 65,
|
||||
databaseGuardCount: 290,
|
||||
}),
|
||||
stepResult('code-boundaries', true, {
|
||||
checkedFiles: 100,
|
||||
checkedScriptFiles: 10,
|
||||
violations: [],
|
||||
}),
|
||||
stepResult('compatibility-snapshot-read', true, {
|
||||
compatibilitySnapshotReadBlockingRiskCount: 0,
|
||||
unclassifiedCompatibilitySnapshotReadCount: 0,
|
||||
compatibilitySnapshotReadBudgetViolationCount: 0,
|
||||
}),
|
||||
stepResult('process-snapshot-read', true, {
|
||||
processSnapshotReadBlockingRiskCount: 0,
|
||||
unclassifiedProcessSnapshotReadCount: 0,
|
||||
processSnapshotReadBudgetViolationCount: 0,
|
||||
}),
|
||||
stepResult('filesystem-master-data-source', true, {
|
||||
fileSystemMasterDataSourceFindingCount: 0,
|
||||
unclassifiedFileSystemMasterDataSourceCount: 0,
|
||||
}),
|
||||
stepResult('historical-storage-assets', true, {
|
||||
mode: 'dry-run',
|
||||
status: 'completed',
|
||||
counts: {
|
||||
referenceTotal: 7,
|
||||
objectTotal: 7,
|
||||
sourceMissing: 0,
|
||||
targetExistsBefore: 7,
|
||||
uploadFailed: 0,
|
||||
updateTotal: 7,
|
||||
},
|
||||
warnings: [],
|
||||
}),
|
||||
stepResult('pre-sku-formalization-repair-plan-validation', true, {
|
||||
valid: true,
|
||||
candidateCount: 0,
|
||||
staleCandidateCount: 0,
|
||||
}),
|
||||
stepResult('authorized-structure-repair-apply-plan', true, {
|
||||
noopStructureRepair: true,
|
||||
exactPhaseCommands: {
|
||||
available: true,
|
||||
missing: [],
|
||||
noopStructureRepair: true,
|
||||
applyStructureDryRun: null,
|
||||
applyStructure: null,
|
||||
resolveBaselineTemplate: null,
|
||||
},
|
||||
blockers: [],
|
||||
}),
|
||||
]
|
||||
|
||||
const checklist = buildFoundationCompletionChecklist({
|
||||
readiness: cleanStepResults[0].data,
|
||||
truthSources: cleanStepResults[4].data,
|
||||
codeBoundaries: cleanStepResults[5].data,
|
||||
compatibilitySnapshotRead: cleanStepResults[6].data,
|
||||
processSnapshotRead: cleanStepResults[7].data,
|
||||
fileSystemMasterDataSource: cleanStepResults[8].data,
|
||||
historicalStorageAssets: cleanStepResults[9].data,
|
||||
preSkuFormalizationRepairPlanValidation: cleanStepResults[10].data,
|
||||
authorizedStructureRepairApplyPlan: cleanStepResults[11].data,
|
||||
stepResults: cleanStepResults,
|
||||
})
|
||||
|
||||
expect(checklist.complete).toBe(true)
|
||||
expect(checklist.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'schema-and-migration-history',
|
||||
status: 'passed',
|
||||
evidence: expect.objectContaining({
|
||||
foundationMigratePlanOk: false,
|
||||
foundationMigratePlanNoop: true,
|
||||
foundationMigratePlanPendingCount: 0,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'authorized-foundation-runbook',
|
||||
status: 'passed',
|
||||
evidence: expect.objectContaining({
|
||||
exactPhaseCommandAvailability: expect.objectContaining({
|
||||
noopStructureRepair: true,
|
||||
applyStructure: false,
|
||||
resolveBaselineTemplate: false,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps completion checklist blocked without exposing row-level evidence', () => {
|
||||
const readiness = {
|
||||
hardReady: false,
|
||||
@ -1705,6 +1814,7 @@ describe('run-master-data-foundation-audit', () => {
|
||||
phase: 'plan',
|
||||
dryRun: true,
|
||||
readyToApply: false,
|
||||
noopStructureRepair: false,
|
||||
requiresExplicitAuthorization: true,
|
||||
executionState: 'plan-only-not-applied',
|
||||
repairSqlArtifact: {
|
||||
@ -1751,6 +1861,7 @@ describe('run-master-data-foundation-audit', () => {
|
||||
phase: 'plan',
|
||||
dryRun: true,
|
||||
readyToApply: false,
|
||||
noopStructureRepair: false,
|
||||
requiresExplicitAuthorization: true,
|
||||
executionState: 'plan-only-not-applied',
|
||||
repairSqlArtifact: {
|
||||
@ -1769,6 +1880,7 @@ describe('run-master-data-foundation-audit', () => {
|
||||
baselineResolveMigrationCount: 86,
|
||||
exactPhaseCommandAvailability: {
|
||||
available: true,
|
||||
noopStructureRepair: false,
|
||||
missing: [],
|
||||
applyStructureDryRun: true,
|
||||
applyStructure: true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user