use crate::commands::migrate::section_executor::{ExecutionMode, SectionExecutor};
use crate::config::Config;
use crate::config::types::TrackingTable;
use crate::migration::section_parser::MigrationSection;
use crate::migration::{
ParsedMigration, discover_migrations, parse_migration_sections, validate_sections,
};
use crate::migration_tracking::section_tracking::{
RecordedSections, SectionStatus, section_statuses, validate_and_sync_section_checksums,
};
use crate::migration_tracking::{
MigrationLock, calculate_checksum, ensure_section_tracking_table, ensure_tracking_table_exists,
format_tracking_table_name, initialize_sections, register_migration_start, version_from_db,
};
use crate::modules::{ModuleRuntime, ModuleSelection, SectionClassification, SkipNotice};
use crate::progress::SectionReporter;
use anyhow::{Context, Result};
use sqlx::PgPool;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
use std::time::Instant;
use tracing::debug;
pub async fn cmd_migrate_apply(
config: &Config,
root_dir: &Path,
target: &crate::config::TargetUrl,
dry_run: bool,
selection: ModuleSelection,
) -> Result<()> {
if dry_run {
println!("Previewing migrations (dry run) - the target database will not be changed");
} else {
println!("Applying migrations to target database");
}
let migrations_dir = root_dir.join(&config.directories.migrations);
let baselines_dir = root_dir.join(&config.directories.baselines);
let migrations = discover_migrations(&migrations_dir)?;
let has_baselines = !crate::migration::discover_baselines(&baselines_dir)?.is_empty();
if !migrations_dir.exists() && !has_baselines {
println!("No migrations directory found - nothing to apply");
return Ok(());
}
let pool =
crate::db::connection::connect_to_database(target.as_str(), "target database").await?;
let lock = MigrationLock::acquire(target.as_str(), &config.migration.tracking_table).await?;
let result =
apply_with_module_guard(config, root_dir, &pool, &migrations, dry_run, &selection).await;
lock.release().await?;
result
}
async fn apply_with_module_guard(
config: &Config,
root_dir: &Path,
pool: &PgPool,
migrations: &[ParsedMigration],
dry_run: bool,
selection: &ModuleSelection,
) -> Result<()> {
ensure_tracking_table_exists(pool, &config.migration.tracking_table).await?;
ensure_section_tracking_table(pool, &config.migration.tracking_table).await?;
let store =
crate::migration_tracking::TrackingStore::new(pool, &config.migration.tracking_table)?;
let baselines_dir = root_dir.join(&config.directories.baselines);
let has_committed_baseline = !crate::migration::discover_baselines(&baselines_dir)?.is_empty();
if has_committed_baseline && store.main_table_is_empty().await? {
anyhow::bail!(
"this target has no baseline established; provision it first:\n \
pgmt migrate provision\n\
`migrate apply` maintains an already-provisioned database — it does not lay \
down baseline content, so applying migrations here would fail against a schema \
the baseline was never applied to."
);
}
let incomplete = store.incomplete_baseline_sections().await?;
if !incomplete.is_empty() {
let base_incomplete_version = incomplete
.iter()
.filter(|(_, _, module)| module.is_none())
.map(|(version, _, _)| *version)
.max();
if let Some(version) = base_incomplete_version {
anyhow::bail!(
"baseline {version} is only partially applied — a `migrate provision` did not \
finish. Complete it with `pgmt migrate provision` before applying migrations."
);
}
let affected_version = incomplete
.iter()
.map(|(version, _, _)| *version)
.max()
.expect("incomplete is non-empty and all rows carry a module");
let affected_modules: BTreeSet<String> = incomplete
.iter()
.filter_map(|(_, _, module)| module.clone())
.collect();
let selection_hits: BTreeSet<&String> = affected_modules
.iter()
.filter(|m| selection.selects(Some(m)))
.collect();
let module_list = affected_modules
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ");
let module_args = affected_modules
.iter()
.cloned()
.collect::<Vec<_>>()
.join(",");
if !selection_hits.is_empty() {
anyhow::bail!(
"module(s) {module_list} have partially applied baseline content (baseline \
{affected_version}) — a `migrate provision --modules {module_args}` did not \
finish. Complete it with `pgmt migrate provision --modules {module_args}` before \
applying its migrations."
);
}
eprintln!(
"Warning: module(s) {module_list} have partially applied baseline content (baseline \
{affected_version}) from an unfinished `migrate provision --modules {module_args}`. \
This apply does not touch them and proceeds; complete their adoption with \
`pgmt migrate provision --modules {module_args}`."
);
}
let mut runtime = ModuleRuntime::load(
pool,
&config.migration.tracking_table,
&root_dir.join(&config.directories.baselines),
)
.await?;
if let Some(named) = selection.named() {
let would_subscribe = runtime.forecast_crossings(migrations).await?;
let still_needs: Vec<String> = runtime
.needing_baseline_content(named.iter())
.into_iter()
.filter(|m| !would_subscribe.contains(m))
.collect();
if !still_needs.is_empty() {
anyhow::bail!(
"adopting module(s) {} here requires baseline content — their pre-baseline \
state lives in the committed baseline, not the migrations.\n\
Adopt via: pgmt migrate provision --modules {}",
still_needs.join(", "),
still_needs.join(",")
);
}
let to_adopt: BTreeSet<String> = named
.iter()
.filter(|m| !would_subscribe.contains(*m))
.cloned()
.collect();
if !dry_run {
runtime.record_adopted(&to_adopt).await?;
}
}
if dry_run {
return preview_pending_migrations(pool, config, migrations, selection, &runtime).await;
}
let applied_any =
apply_pending_migrations(pool, config, migrations, selection, &mut runtime).await?;
if !applied_any {
println!("Nothing to apply — up to date.");
}
Ok(())
}
struct AppliedState {
baseline_version: Option<u64>,
applied_migrations: HashMap<u64, String>,
}
impl AppliedState {
async fn load(pool: &PgPool, tracking_table: &TrackingTable) -> Result<Self> {
let tracking_table_name = format_tracking_table_name(tracking_table)?;
let rows: Vec<(i64, String, bool)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT version, checksum, is_baseline FROM {}",
tracking_table_name
)))
.fetch_all(pool)
.await?;
let baseline_version = rows
.iter()
.filter(|(_, _, is_baseline)| *is_baseline)
.map(|(version, _, _)| version_from_db(*version))
.max();
let applied_migrations = rows
.into_iter()
.filter(|(_, _, is_baseline)| !*is_baseline)
.map(|(v, checksum, _)| (version_from_db(v), checksum))
.collect();
Ok(Self {
baseline_version,
applied_migrations,
})
}
fn covered_by_baseline(&self, version: u64) -> bool {
self.baseline_version.is_some_and(|bv| version <= bv)
}
fn registered(&self, version: u64) -> Option<&String> {
self.applied_migrations.get(&version)
}
}
fn read_and_parse_sections(migration: &ParsedMigration) -> Result<(String, Vec<MigrationSection>)> {
let migration_sql = std::fs::read_to_string(&migration.path).with_context(|| {
format!(
"Failed to read migration file: {}",
migration.path.display()
)
})?;
let sections = parse_migration_sections(&migration.path, &migration_sql)
.with_context(|| format!("Failed to parse migration {}", migration.version))?;
validate_sections(§ions).with_context(|| {
format!(
"Invalid section configuration in migration {}",
migration.version
)
})?;
Ok((migration_sql, sections))
}
fn classify_migration(
sections: &[MigrationSection],
recorded_sections: &RecordedSections,
version: u64,
registered: bool,
vocabulary: &BTreeSet<String>,
selection: &ModuleSelection,
established: &BTreeSet<String>,
) -> Result<(BTreeMap<String, SectionStatus>, SectionClassification)> {
let statuses = if registered {
section_statuses(recorded_sections.for_version(version))?
} else {
Default::default()
};
let classification =
crate::modules::classify_sections(sections, &statuses, vocabulary, selection, established);
Ok((statuses, classification))
}
async fn preview_pending_migrations(
pool: &PgPool,
config: &Config,
migrations: &[ParsedMigration],
selection: &ModuleSelection,
runtime: &ModuleRuntime,
) -> Result<()> {
let applied = AppliedState::load(pool, &config.migration.tracking_table).await?;
let recorded_sections =
RecordedSections::load_migrations(pool, &config.migration.tracking_table).await?;
let mut would_run_any = false;
for migration in migrations {
if applied.covered_by_baseline(migration.version) {
continue;
}
let (_, sections) = read_and_parse_sections(migration)?;
let registered = applied.registered(migration.version).is_some();
let (
statuses,
SectionClassification {
to_run,
to_satisfy,
selected,
skipped,
coupling_violation: _,
},
) = classify_migration(
§ions,
&recorded_sections,
migration.version,
registered,
runtime.established(),
selection,
runtime.established(),
)?;
if to_run.is_empty() && to_satisfy.is_empty() && skipped.is_empty() {
continue;
}
if !to_run.is_empty() || !to_satisfy.is_empty() {
would_run_any = true;
let done = selected
.iter()
.filter(|(_, s)| statuses.get(&s.name).is_some_and(|st| st.is_covered()))
.count();
if registered {
println!(
"\nWould resume migration {} - {} ({}/{} selected sections already complete)",
migration.version,
migration.description,
done,
selected.len()
);
} else {
println!(
"\nWould apply migration {} - {}",
migration.version, migration.description
);
}
} else {
println!(
"\nMigration {} - {}",
migration.version, migration.description
);
}
for (_, section) in &to_run {
println!(" Would run section '{}'", section.name);
}
for (_, section) in &to_satisfy {
println!(
" Would record section '{}' satisfied: source '{}' is established here \
(objects already present; nothing to run)",
section.name,
section.remaps.as_deref().unwrap_or("?"),
);
}
for skip in &skipped {
match skip.notice {
SkipNotice::Drift => println!(
" Would skip module '{}' sections (established on this target but not in \
the requested set - this is schema drift until a deploy names it: \
--modules ...,{})",
skip.module, skip.module
),
SkipNotice::NotEstablished => println!(
" Would skip module '{}' sections (not established here)",
skip.module
),
}
}
}
if !would_run_any {
println!("Nothing to apply — up to date.");
}
Ok(())
}
pub(crate) async fn apply_pending_migrations(
pool: &PgPool,
config: &Config,
migrations: &[ParsedMigration],
selection: &ModuleSelection,
runtime: &mut ModuleRuntime,
) -> Result<bool> {
let mut applied_any = false;
let applied = AppliedState::load(pool, &config.migration.tracking_table).await?;
let recorded_sections =
RecordedSections::load_migrations(pool, &config.migration.tracking_table).await?;
for migration in migrations {
runtime
.cross_re_anchors_through(Some(migration.version.saturating_sub(1)))
.await?;
if applied.covered_by_baseline(migration.version) {
let below_watermark = applied
.baseline_version
.is_some_and(|bv| migration.version < bv);
if below_watermark && applied.registered(migration.version).is_none() {
eprintln!(
"Warning: migration {} ({}) is below the baseline watermark ({}) \
and was never applied to this database. It will never run here or on \
any other target (fresh provisions replay only migrations after the \
baseline). If its changes are needed, run 'pgmt migrate update {}' to \
regenerate it above the baseline.",
migration.version,
migration.description,
applied
.baseline_version
.expect("below_watermark implies a baseline exists"),
migration.version,
);
} else {
debug!(
"Migration {} is covered by the baseline, skipping",
migration.version
);
}
runtime
.cross_re_anchors_through(Some(migration.version))
.await?;
continue;
}
let (migration_sql, sections) = read_and_parse_sections(migration)?;
let checksum = calculate_checksum(&migration_sql);
let registered = applied.registered(migration.version);
let acquirable = crate::modules::acquisition_pairs(§ions);
let mut pending_crossing = runtime
.gate_re_anchor_at(migration.version, &acquirable)
.await?;
macro_rules! commit_pending {
() => {
if let Some(pending) = pending_crossing.take() {
runtime.commit_crossing(pending).await?;
}
};
}
if let Some(stored_file_checksum) = registered {
let had_checksummed = validate_and_sync_section_checksums(
pool,
&config.migration.tracking_table,
migration.version,
false,
§ions,
recorded_sections.for_version(migration.version),
)
.await?;
if stored_file_checksum != &checksum {
if !had_checksummed {
anyhow::bail!(
"Migration {} has been modified after being applied!\n\
Expected checksum: {}\n\
Actual checksum: {}\n\n\
Migrations must be immutable once applied. If you need to make changes:\n\
• Create a new migration with the changes\n\
• Or roll back and recreate this migration (dangerous in production)",
migration.version,
stored_file_checksum,
checksum
);
}
crate::migration_tracking::update_stored_file_checksum(
pool,
&config.migration.tracking_table,
migration.version,
false,
&checksum,
)
.await?;
}
}
let vocabulary: BTreeSet<String> = pending_crossing
.as_ref()
.map(|p| p.rewritten().clone())
.unwrap_or_else(|| runtime.established().clone());
let (
statuses,
SectionClassification {
to_run,
to_satisfy,
selected,
skipped,
coupling_violation,
},
) = classify_migration(
§ions,
&recorded_sections,
migration.version,
registered.is_some(),
&vocabulary,
selection,
runtime.established(),
)?;
for skip in &skipped {
match skip.notice {
SkipNotice::Drift => eprintln!(
"Warning: module '{}' is established on this target but not in the \
requested set — its sections in migration {} were skipped. This is \
schema drift until a deploy names it (--modules ...,{}).",
skip.module, migration.version, skip.module
),
SkipNotice::NotEstablished => println!(
"Skipping module '{}' sections in migration {} (not established here)",
skip.module, migration.version
),
}
}
if let Some(v) = &coupling_violation {
anyhow::bail!(
"migration {} couples module '{}' (section '{}') ahead of selected \
section '{}'; deploy them together (--modules ...,{})",
migration.version,
v.module,
v.earlier_section,
v.section,
v.module
);
}
if registered.is_some() {
if statuses.is_empty() {
anyhow::bail!(
"tracking state corrupt: version {} has a tracking row but no section \
rows; this should be impossible after schema migration — investigate \
before re-running",
migration.version
);
}
let fully_applied = selected.iter().all(|(_, s)| {
statuses.get(&s.name).is_some_and(|st| st.is_covered())
|| crate::modules::remap_source_held(s, runtime.established())
});
if fully_applied && to_satisfy.is_empty() {
debug!("Migration {} already applied, skipping", migration.version);
commit_pending!();
continue;
}
let done = selected
.iter()
.filter(|(_, s)| statuses.get(&s.name).is_some_and(|st| st.is_covered()))
.count();
applied_any = true;
println!(
"\nResuming migration {} - {} ({}/{} selected sections already complete)",
migration.version,
migration.description,
done,
selected.len()
);
let missing: Vec<(i32, crate::migration::section_parser::MigrationSection)> = to_run
.iter()
.filter(|(_, s)| !statuses.contains_key(&s.name))
.cloned()
.collect();
if !missing.is_empty() {
initialize_sections(
pool,
&config.migration.tracking_table,
migration.version,
false,
&missing,
)
.await?;
}
if !to_satisfy.is_empty() {
crate::migration_tracking::section_tracking::record_sections_satisfied(
pool,
&config.migration.tracking_table,
migration.version,
false,
&to_satisfy,
)
.await?;
}
} else {
if selected.is_empty() {
debug!(
"Migration {} has no selected sections, skipping",
migration.version
);
commit_pending!();
continue;
}
applied_any = true;
println!(
"\nApplying migration {} - {}",
migration.version, migration.description
);
register_migration_start(
pool,
&config.migration.tracking_table,
migration.version,
&migration.description,
&checksum,
&to_run,
&to_satisfy,
)
.await?;
}
for (_, section) in &to_satisfy {
println!(
"Section '{}' of migration {}: source '{}' is established here — \
recorded satisfied (objects already present; nothing to run)",
section.name,
migration.version,
section.remaps.as_deref().unwrap_or("?"),
);
}
let start = Instant::now();
let reporter = SectionReporter::new(to_run.len(), false);
let mut executor = SectionExecutor::new(
pool.clone(),
config.migration.tracking_table.clone(),
reporter,
ExecutionMode::Production,
false,
);
for (_, section) in &to_run {
executor
.execute_section(migration.version, section)
.await
.with_context(|| {
format!(
"Migration {} failed at section '{}'",
migration.version, section.name
)
})?;
}
let duration = start.elapsed();
let reporter = SectionReporter::new(to_run.len(), false);
reporter.migration_summary(duration, to_run.len());
commit_pending!();
}
runtime.cross_re_anchors_through(None).await?;
Ok(applied_any)
}