use crate::config::types::TrackingTable;
use crate::migration::section_parser::MigrationSection;
use crate::migration_tracking::{calculate_checksum, version_from_db, version_to_db};
use anyhow::{Context, Result};
use sqlx::PgPool;
use std::collections::BTreeMap;
use std::str::FromStr;
const PGMT_VERSION: &str = env!("CARGO_PKG_VERSION");
pub(crate) fn format_sections_table_name(tracking_table: &TrackingTable) -> String {
format!(
r#""{}"."{}_sections""#,
tracking_table.schema, tracking_table.name
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SectionStatus {
Pending,
Running,
Completed,
Satisfied,
Failed,
}
impl SectionStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Running => "running",
Self::Completed => "completed",
Self::Satisfied => "satisfied",
Self::Failed => "failed",
}
}
pub fn is_covered(&self) -> bool {
matches!(self, Self::Completed | Self::Satisfied)
}
}
impl FromStr for SectionStatus {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"pending" => Ok(Self::Pending),
"running" => Ok(Self::Running),
"completed" => Ok(Self::Completed),
"satisfied" => Ok(Self::Satisfied),
"failed" => Ok(Self::Failed),
_ => Err(anyhow::anyhow!("Unknown section status: {}", s)),
}
}
}
pub async fn ensure_section_tracking_table(
pool: &PgPool,
tracking_table: &TrackingTable,
) -> Result<()> {
let sections_table = format_sections_table_name(tracking_table);
let table_existed: bool = sqlx::query_scalar("SELECT to_regclass($1) IS NOT NULL")
.bind(§ions_table)
.fetch_one(pool)
.await
.context("Failed to probe for the section tracking table")?;
if !table_existed {
let mut tx = pool.begin().await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
r#"
CREATE TABLE IF NOT EXISTS {} (
migration_version BIGINT NOT NULL,
is_baseline BOOLEAN NOT NULL,
section_name TEXT NOT NULL,
section_order INT NOT NULL,
status TEXT NOT NULL,
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
attempts INT DEFAULT 0,
last_error TEXT,
rows_affected BIGINT,
duration_ms BIGINT,
checksum TEXT,
mode TEXT,
module TEXT,
applied_by TEXT,
pgmt_version TEXT,
PRIMARY KEY (migration_version, is_baseline, section_name)
)
"#,
sections_table
)))
.execute(&mut *tx)
.await
.context("Failed to create section tracking table")?;
backfill_synthetic_legacy_sections(&mut tx, tracking_table, §ions_table).await?;
tx.commit()
.await
.context("Failed to introduce section tracking table")?;
} else {
migrate_section_table_schema(pool, §ions_table).await?;
}
crate::migration_tracking::TrackingStore::new(pool, tracking_table)?
.ensure_subscription_tables()
.await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"CREATE INDEX IF NOT EXISTS idx_{}_status ON {}(migration_version, status)",
sections_table.replace("\"", "").replace(".", "_"),
sections_table
)))
.execute(pool)
.await
.context("Failed to create section tracking table index")?;
Ok(())
}
async fn migrate_section_table_schema(pool: &PgPool, sections_table: &str) -> Result<()> {
let existing_columns: Vec<String> = sqlx::query_scalar(
"SELECT attname::text FROM pg_attribute
WHERE attrelid = $1::regclass AND attnum > 0 AND NOT attisdropped",
)
.bind(sections_table)
.fetch_all(pool)
.await
.with_context(|| format!("Failed to inspect columns of {}", sections_table))?;
let has = |name: &str| existing_columns.iter().any(|c| c == name);
if !has("is_baseline") {
let mut tx = pool.begin().await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"ALTER TABLE {} ADD COLUMN is_baseline BOOLEAN",
sections_table
)))
.execute(&mut *tx)
.await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {} SET is_baseline = FALSE",
sections_table
)))
.execute(&mut *tx)
.await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"ALTER TABLE {} ALTER COLUMN is_baseline SET NOT NULL",
sections_table
)))
.execute(&mut *tx)
.await?;
tx.commit()
.await
.with_context(|| format!("Failed to add is_baseline to {}", sections_table))?;
}
for column in ["checksum", "mode", "module", "applied_by", "pgmt_version"] {
if !has(column) {
sqlx::query(sqlx::AssertSqlSafe(format!(
"ALTER TABLE {} ADD COLUMN {} TEXT",
sections_table, column
)))
.execute(pool)
.await
.with_context(|| format!("Failed to add {} to {}", column, sections_table))?;
}
}
crate::migration_tracking::ensure_primary_key(
pool,
sections_table,
&["migration_version", "is_baseline", "section_name"],
)
.await?;
Ok(())
}
async fn backfill_synthetic_legacy_sections(
conn: &mut sqlx::PgConnection,
tracking_table: &TrackingTable,
sections_table: &str,
) -> Result<()> {
let main_table = crate::migration_tracking::format_tracking_table_name(tracking_table)?;
let main_exists: Option<String> = sqlx::query_scalar("SELECT to_regclass($1)::text")
.bind(&main_table)
.fetch_one(&mut *conn)
.await?;
if main_exists.is_none() {
return Ok(());
}
sqlx::query(sqlx::AssertSqlSafe(format!(
"INSERT INTO {sections}
(migration_version, is_baseline, section_name, section_order,
status, completed_at, attempts)
SELECT m.version, m.is_baseline, 'default', 0, 'completed', m.applied_at, 0
FROM {main} m
WHERE NOT EXISTS (
SELECT 1 FROM {sections} s
WHERE s.migration_version = m.version AND s.is_baseline = m.is_baseline)",
sections = sections_table,
main = main_table,
)))
.execute(&mut *conn)
.await
.with_context(|| {
format!(
"Failed to backfill synthetic legacy sections into {}",
sections_table
)
})?;
Ok(())
}
pub(crate) async fn insert_pending_section<'e>(
executor: impl sqlx::PgExecutor<'e>,
sections_table: &str,
version: u64,
is_baseline: bool,
order: i32,
section: &MigrationSection,
) -> Result<()> {
sqlx::query(sqlx::AssertSqlSafe(format!(
"INSERT INTO {} (migration_version, is_baseline, section_name, section_order,
status, attempts, checksum, mode, module, pgmt_version)
VALUES ($1, $2, $3, $4, $5, 0, $6, $7, $8, $9)
ON CONFLICT (migration_version, is_baseline, section_name) DO NOTHING",
sections_table
)))
.bind(version_to_db(version)?)
.bind(is_baseline)
.bind(§ion.name)
.bind(order)
.bind(SectionStatus::Pending.as_str())
.bind(calculate_checksum(§ion.checksum_content()))
.bind(section.mode.as_str())
.bind(section.module.as_deref())
.bind(PGMT_VERSION)
.execute(executor)
.await?;
Ok(())
}
pub(crate) async fn insert_satisfied_section(
conn: &mut sqlx::PgConnection,
sections_table: &str,
version: u64,
is_baseline: bool,
order: i32,
section: &MigrationSection,
) -> Result<()> {
insert_pending_section(
&mut *conn,
sections_table,
version,
is_baseline,
order,
section,
)
.await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {} SET status = $1, completed_at = NOW(), applied_by = CURRENT_USER
WHERE migration_version = $2 AND is_baseline = $3 AND section_name = $4
AND status = 'pending'",
sections_table
)))
.bind(SectionStatus::Satisfied.as_str())
.bind(version_to_db(version)?)
.bind(is_baseline)
.bind(§ion.name)
.execute(&mut *conn)
.await?;
Ok(())
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct StoredSection {
pub section_name: String,
pub status: String,
pub checksum: Option<String>,
pub mode: Option<String>,
pub section_order: i32,
pub module: Option<String>,
}
const STORED_SECTION_COLUMNS: &str = "section_name, status, checksum, mode, section_order, module";
pub async fn fetch_section_rows(
pool: &PgPool,
tracking_table: &TrackingTable,
version: u64,
is_baseline: bool,
) -> Result<Vec<StoredSection>> {
let rows = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT {STORED_SECTION_COLUMNS}
FROM {} WHERE migration_version = $1 AND is_baseline = $2",
format_sections_table_name(tracking_table)
)))
.bind(version_to_db(version)?)
.bind(is_baseline)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub struct RecordedSections(BTreeMap<u64, Vec<StoredSection>>);
impl RecordedSections {
pub async fn load_migrations(pool: &PgPool, tracking_table: &TrackingTable) -> Result<Self> {
#[derive(sqlx::FromRow)]
struct VersionedSection {
migration_version: i64,
#[sqlx(flatten)]
section: StoredSection,
}
let rows: Vec<VersionedSection> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT migration_version, {STORED_SECTION_COLUMNS}
FROM {} WHERE NOT is_baseline",
format_sections_table_name(tracking_table)
)))
.fetch_all(pool)
.await
.context("Failed to read the recorded migration sections")?;
let mut by_version: BTreeMap<u64, Vec<StoredSection>> = BTreeMap::new();
for row in rows {
by_version
.entry(version_from_db(row.migration_version))
.or_default()
.push(row.section);
}
Ok(Self(by_version))
}
pub fn for_version(&self, version: u64) -> &[StoredSection] {
self.0.get(&version).map_or(&[], Vec::as_slice)
}
}
pub async fn validate_and_sync_section_checksums(
pool: &PgPool,
tracking_table: &TrackingTable,
version: u64,
is_baseline: bool,
file_sections: &[MigrationSection],
rows: &[StoredSection],
) -> Result<bool> {
let sections_table = format_sections_table_name(tracking_table);
let kind = if is_baseline { "baseline" } else { "migration" };
let baseline_flag = if is_baseline { " --baseline" } else { "" };
let mut file_map: BTreeMap<&str, (i32, String, &'static str, Option<&str>)> = BTreeMap::new();
for (idx, section) in file_sections.iter().enumerate() {
file_map.insert(
section.name.as_str(),
(
idx as i32,
calculate_checksum(§ion.checksum_content()),
section.mode.as_str(),
section.module.as_deref(),
),
);
}
let mut has_checksummed = false;
for row in rows {
let StoredSection {
section_name: name,
status,
checksum: stored_checksum,
mode: stored_mode,
section_order: stored_order,
module: stored_module,
} = row;
let Some(stored_checksum) = stored_checksum else {
continue;
};
has_checksummed = true;
let completed = matches!(
status.parse::<SectionStatus>(),
Ok(s) if s.is_covered()
);
let Some((order, checksum, mode, module)) = file_map.get(name.as_str()) else {
if completed {
anyhow::bail!(
"section '{name}' of {kind} {version} was applied but no longer exists in \
the file. Applied sections are immutable. Create a new migration for \
further changes."
);
}
continue;
};
if completed {
if stored_checksum != checksum {
anyhow::bail!(
"section '{name}' of {kind} {version} was modified after it was applied.\n\
Expected checksum: {stored_checksum}\n\
Actual checksum: {checksum}\n\n\
Applied sections are immutable. Create a new migration for further changes. \
If the edit was conscious and its effects already match the database, \
re-stamp the stored checksum: pgmt migrate resolve --restamp {version}/{name}{baseline_flag}."
);
}
if *stored_order != *order {
anyhow::bail!(
"section '{name}' of {kind} {version} was reordered after it was applied \
(was position {stored_order}, now {order}). Applied sections are immutable. \
Create a new migration for further changes. If the reorder is intentional, \
accept it with `pgmt migrate resolve --restamp {version}{baseline_flag}`."
);
}
if let Some(sm) = stored_mode
&& sm != mode
{
anyhow::bail!(
"section '{name}' of {kind} {version} changed transaction mode after it was \
applied (was '{sm}', now '{mode}'). Applied sections are immutable. Create a \
new migration for further changes."
);
}
if stored_module.as_deref() != *module {
anyhow::bail!(
"section '{name}' of {kind} {version} was re-tagged to a different module \
after it was applied. Applied sections are immutable. Create a new migration \
for further changes."
);
}
} else if stored_checksum != checksum {
println!("section '{name}' changed since registration; updating");
sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {} SET checksum = $1, mode = $2, module = $3
WHERE migration_version = $4 AND is_baseline = $5 AND section_name = $6",
sections_table
)))
.bind(checksum)
.bind(*mode)
.bind(*module)
.bind(version_to_db(version)?)
.bind(is_baseline)
.bind(name)
.execute(pool)
.await?;
}
}
Ok(has_checksummed)
}
pub async fn initialize_sections(
pool: &PgPool,
tracking_table: &TrackingTable,
migration_version: u64,
is_baseline: bool,
sections: &[(i32, MigrationSection)],
) -> Result<()> {
let sections_table = format_sections_table_name(tracking_table);
for (order, section) in sections {
insert_pending_section(
pool,
§ions_table,
migration_version,
is_baseline,
*order,
section,
)
.await?;
}
Ok(())
}
pub async fn record_sections_satisfied(
pool: &PgPool,
tracking_table: &TrackingTable,
version: u64,
is_baseline: bool,
sections: &[(i32, MigrationSection)],
) -> Result<()> {
if sections.is_empty() {
return Ok(());
}
let sections_table = format_sections_table_name(tracking_table);
let mut tx = pool.begin().await?;
for (order, section) in sections {
insert_satisfied_section(
&mut tx,
§ions_table,
version,
is_baseline,
*order,
section,
)
.await?;
}
tx.commit()
.await
.context("Failed to record satisfied sections")?;
Ok(())
}
pub fn section_statuses(rows: &[StoredSection]) -> Result<BTreeMap<String, SectionStatus>> {
rows.iter()
.map(|row| {
Ok((
row.section_name.clone(),
SectionStatus::from_str(&row.status)?,
))
})
.collect()
}
pub async fn get_section_status(
pool: &PgPool,
tracking_table: &TrackingTable,
migration_version: u64,
is_baseline: bool,
section_name: &str,
) -> Result<Option<SectionStatus>> {
let sections_table = format_sections_table_name(tracking_table);
let row: Option<(String,)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT status FROM {} WHERE migration_version = $1 AND is_baseline = $2 AND section_name = $3",
sections_table
)))
.bind(version_to_db(migration_version)?)
.bind(is_baseline)
.bind(section_name)
.fetch_optional(pool)
.await?;
row.map(|(status,)| SectionStatus::from_str(&status))
.transpose()
}
pub async fn record_section_start(
pool: &PgPool,
tracking_table: &TrackingTable,
migration_version: u64,
is_baseline: bool,
section_name: &str,
) -> Result<()> {
let sections_table = format_sections_table_name(tracking_table);
sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {}
SET status = $1, started_at = NOW(), attempts = attempts + 1,
applied_by = CURRENT_USER
WHERE migration_version = $2 AND is_baseline = $3 AND section_name = $4",
sections_table
)))
.bind(SectionStatus::Running.as_str())
.bind(version_to_db(migration_version)?)
.bind(is_baseline)
.bind(section_name)
.execute(pool)
.await?;
Ok(())
}
pub async fn record_section_complete<'e>(
executor: impl sqlx::PgExecutor<'e>,
tracking_table: &TrackingTable,
migration_version: u64,
is_baseline: bool,
section_name: &str,
rows_affected: Option<i64>,
duration_ms: i64,
) -> Result<()> {
let sections_table = format_sections_table_name(tracking_table);
sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {}
SET status = $1, completed_at = NOW(), rows_affected = $2, duration_ms = $3
WHERE migration_version = $4 AND is_baseline = $5 AND section_name = $6",
sections_table
)))
.bind(SectionStatus::Completed.as_str())
.bind(rows_affected)
.bind(duration_ms)
.bind(version_to_db(migration_version)?)
.bind(is_baseline)
.bind(section_name)
.execute(executor)
.await?;
Ok(())
}
pub async fn record_section_failed(
pool: &PgPool,
tracking_table: &TrackingTable,
migration_version: u64,
is_baseline: bool,
section_name: &str,
error: &str,
) -> Result<()> {
let sections_table = format_sections_table_name(tracking_table);
sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {}
SET status = $1, last_error = $2
WHERE migration_version = $3 AND is_baseline = $4 AND section_name = $5",
sections_table
)))
.bind(SectionStatus::Failed.as_str())
.bind(error)
.bind(version_to_db(migration_version)?)
.bind(is_baseline)
.bind(section_name)
.execute(pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_section_status_as_str() {
assert_eq!(SectionStatus::Pending.as_str(), "pending");
assert_eq!(SectionStatus::Running.as_str(), "running");
assert_eq!(SectionStatus::Completed.as_str(), "completed");
assert_eq!(SectionStatus::Satisfied.as_str(), "satisfied");
assert_eq!(SectionStatus::Failed.as_str(), "failed");
}
#[test]
fn test_section_status_covered() {
assert!(SectionStatus::Completed.is_covered());
assert!(SectionStatus::Satisfied.is_covered());
assert!(!SectionStatus::Pending.is_covered());
assert!(!SectionStatus::Running.is_covered());
assert!(!SectionStatus::Failed.is_covered());
}
#[test]
fn test_section_status_from_str() {
assert_eq!(
"pending".parse::<SectionStatus>().unwrap(),
SectionStatus::Pending
);
assert_eq!(
"running".parse::<SectionStatus>().unwrap(),
SectionStatus::Running
);
assert_eq!(
"completed".parse::<SectionStatus>().unwrap(),
SectionStatus::Completed
);
assert_eq!(
"satisfied".parse::<SectionStatus>().unwrap(),
SectionStatus::Satisfied
);
assert_eq!(
"failed".parse::<SectionStatus>().unwrap(),
SectionStatus::Failed
);
assert!("invalid".parse::<SectionStatus>().is_err());
}
}