diff --git a/furniture-3d-system/scripts/apply-prisma-structure-repair.js b/furniture-3d-system/scripts/apply-prisma-structure-repair.js index 3e5be6a6..b4146b86 100644 --- a/furniture-3d-system/scripts/apply-prisma-structure-repair.js +++ b/furniture-3d-system/scripts/apply-prisma-structure-repair.js @@ -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, diff --git a/furniture-3d-system/scripts/plan-prisma-baseline-repair.js b/furniture-3d-system/scripts/plan-prisma-baseline-repair.js index 968ec540..2aae4cf2 100644 --- a/furniture-3d-system/scripts/plan-prisma-baseline-repair.js +++ b/furniture-3d-system/scripts/plan-prisma-baseline-repair.js @@ -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, diff --git a/furniture-3d-system/scripts/rehearse-prisma-structure-repair.js b/furniture-3d-system/scripts/rehearse-prisma-structure-repair.js index f9852a17..79713149 100644 --- a/furniture-3d-system/scripts/rehearse-prisma-structure-repair.js +++ b/furniture-3d-system/scripts/rehearse-prisma-structure-repair.js @@ -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, }) } diff --git a/furniture-3d-system/scripts/run-database-foundation-migrations.js b/furniture-3d-system/scripts/run-database-foundation-migrations.js index f55f8940..961c26b1 100644 --- a/furniture-3d-system/scripts/run-database-foundation-migrations.js +++ b/furniture-3d-system/scripts/run-database-foundation-migrations.js @@ -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) } diff --git a/furniture-3d-system/scripts/run-master-data-foundation-audit.js b/furniture-3d-system/scripts/run-master-data-foundation-audit.js index 6f04f9ee..d7125f4d 100644 --- a/furniture-3d-system/scripts/run-master-data-foundation-audit.js +++ b/furniture-3d-system/scripts/run-master-data-foundation-audit.js @@ -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'), }, diff --git a/furniture-3d-system/tests/scripts/apply-prisma-structure-repair.test.js b/furniture-3d-system/tests/scripts/apply-prisma-structure-repair.test.js index 113961ac..42536bab 100644 --- a/furniture-3d-system/tests/scripts/apply-prisma-structure-repair.test.js +++ b/furniture-3d-system/tests/scripts/apply-prisma-structure-repair.test.js @@ -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({ diff --git a/furniture-3d-system/tests/scripts/plan-prisma-baseline-repair.test.js b/furniture-3d-system/tests/scripts/plan-prisma-baseline-repair.test.js index c529153e..cdee45bc 100644 --- a/furniture-3d-system/tests/scripts/plan-prisma-baseline-repair.test.js +++ b/furniture-3d-system/tests/scripts/plan-prisma-baseline-repair.test.js @@ -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({ diff --git a/furniture-3d-system/tests/scripts/rehearse-prisma-structure-repair.test.js b/furniture-3d-system/tests/scripts/rehearse-prisma-structure-repair.test.js index 37c3cee2..d2fa96e0 100644 --- a/furniture-3d-system/tests/scripts/rehearse-prisma-structure-repair.test.js +++ b/furniture-3d-system/tests/scripts/rehearse-prisma-structure-repair.test.js @@ -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([ diff --git a/furniture-3d-system/tests/scripts/run-database-foundation-migrations.test.js b/furniture-3d-system/tests/scripts/run-database-foundation-migrations.test.js index eaabbd1f..b582aa76 100644 --- a/furniture-3d-system/tests/scripts/run-database-foundation-migrations.test.js +++ b/furniture-3d-system/tests/scripts/run-database-foundation-migrations.test.js @@ -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, diff --git a/furniture-3d-system/tests/scripts/run-master-data-foundation-audit.test.js b/furniture-3d-system/tests/scripts/run-master-data-foundation-audit.test.js index 8af77d42..99b7f327 100644 --- a/furniture-3d-system/tests/scripts/run-master-data-foundation-audit.test.js +++ b/furniture-3d-system/tests/scripts/run-master-data-foundation-audit.test.js @@ -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,