pub mod advisory_lock;
pub mod section_tracking;
pub mod store;
use crate::config::types::TrackingTable;
use anyhow::{Context, Result};
use sqlx::PgPool;
pub use advisory_lock::MigrationLock;
pub use section_tracking::{ensure_section_tracking_table, initialize_sections};
pub use store::TrackingStore;
pub fn version_to_db(version: u64) -> Result<i64> {
i64::try_from(version).with_context(|| {
format!(
"Migration version {} is too large for database storage (exceeds i64::MAX). \
This typically indicates a timestamp far in the future or corrupted version data.",
version
)
})
}
pub fn version_from_db(version: i64) -> u64 {
if version < 0 {
tracing::warn!(
"Found negative migration version in database: {}. This indicates corrupted data.",
version
);
0
} else {
version as u64 }
}
pub fn format_tracking_table_name(tracking_table: &TrackingTable) -> Result<String> {
fn is_valid_sql_identifier(name: &str) -> bool {
if name.is_empty() {
return false;
}
let first_char = name.chars().next().unwrap();
if !first_char.is_alphabetic() && first_char != '_' {
return false;
}
name.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '$')
}
if !is_valid_sql_identifier(&tracking_table.schema) {
return Err(anyhow::anyhow!(
"Invalid schema name '{}': must contain only letters, numbers, underscores, and dollar signs, starting with letter or underscore",
tracking_table.schema
));
}
if !is_valid_sql_identifier(&tracking_table.name) {
return Err(anyhow::anyhow!(
"Invalid table name '{}': must contain only letters, numbers, underscores, and dollar signs, starting with letter or underscore",
tracking_table.name
));
}
Ok(format!(
r#""{}"."{}""#,
tracking_table.schema, tracking_table.name
))
}
pub async fn ensure_tracking_table_exists(
pool: &PgPool,
tracking_table: &TrackingTable,
) -> Result<()> {
let tracking_table_name = format_tracking_table_name(tracking_table)?;
sqlx::query(sqlx::AssertSqlSafe(format!(
r#"
CREATE TABLE IF NOT EXISTS {} (
version BIGINT NOT NULL,
description TEXT NOT NULL,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
checksum TEXT NOT NULL,
applied_by TEXT DEFAULT CURRENT_USER,
is_baseline BOOLEAN NOT NULL,
PRIMARY KEY (version, is_baseline)
)
"#,
tracking_table_name
)))
.execute(pool)
.await
.with_context(|| format!("Failed to create tracking table {}", tracking_table_name))?;
migrate_tracking_table_schema(pool, tracking_table, &tracking_table_name).await?;
Ok(())
}
pub(crate) async fn primary_key_columns(
pool: &PgPool,
qualified_name: &str,
) -> Result<Vec<String>> {
let columns: Vec<String> = sqlx::query_scalar(
"SELECT a.attname::text
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid
AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary
ORDER BY array_position(i.indkey, a.attnum)",
)
.bind(qualified_name)
.fetch_all(pool)
.await
.with_context(|| format!("Failed to inspect primary key of {}", qualified_name))?;
Ok(columns)
}
pub(crate) async fn ensure_primary_key(
pool: &PgPool,
qualified_name: &str,
expected_columns: &[&str],
) -> Result<()> {
let current = primary_key_columns(pool, qualified_name).await?;
if current == expected_columns {
return Ok(());
}
let pk_name: Option<String> = sqlx::query_scalar(
"SELECT conname::text FROM pg_constraint
WHERE conrelid = $1::regclass AND contype = 'p'",
)
.bind(qualified_name)
.fetch_optional(pool)
.await?;
let mut tx = pool.begin().await?;
if let Some(pk_name) = pk_name {
sqlx::query(sqlx::AssertSqlSafe(format!(
r#"ALTER TABLE {} DROP CONSTRAINT "{}""#,
qualified_name,
pk_name.replace('"', "")
)))
.execute(&mut *tx)
.await?;
}
sqlx::query(sqlx::AssertSqlSafe(format!(
"ALTER TABLE {} ADD PRIMARY KEY ({})",
qualified_name,
expected_columns.join(", ")
)))
.execute(&mut *tx)
.await?;
tx.commit().await.with_context(|| {
format!(
"Failed to migrate primary key of {} to ({})",
qualified_name,
expected_columns.join(", ")
)
})?;
Ok(())
}
async fn migrate_tracking_table_schema(
pool: &PgPool,
tracking_table: &TrackingTable,
tracking_table_name: &str,
) -> Result<()> {
let existing_columns: Vec<String> = sqlx::query_scalar(
"SELECT column_name FROM information_schema.columns \
WHERE table_schema = $1 AND table_name = $2",
)
.bind(&tracking_table.schema)
.bind(&tracking_table.name)
.fetch_all(pool)
.await
.with_context(|| format!("Failed to inspect columns of {}", tracking_table_name))?;
let has = |name: &str| existing_columns.iter().any(|c| c == name);
if !has("applied_by") {
sqlx::query(sqlx::AssertSqlSafe(format!(
"ALTER TABLE {} ADD COLUMN applied_by TEXT DEFAULT CURRENT_USER",
tracking_table_name
)))
.execute(pool)
.await
.with_context(|| format!("Failed to add applied_by to {}", tracking_table_name))?;
}
if !has("is_baseline") {
let mut tx = pool.begin().await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"ALTER TABLE {} ADD COLUMN is_baseline BOOLEAN",
tracking_table_name
)))
.execute(&mut *tx)
.await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {} SET is_baseline = FALSE",
tracking_table_name
)))
.execute(&mut *tx)
.await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"ALTER TABLE {} ALTER COLUMN is_baseline SET NOT NULL",
tracking_table_name
)))
.execute(&mut *tx)
.await?;
tx.commit()
.await
.with_context(|| format!("Failed to add is_baseline to {}", tracking_table_name))?;
}
ensure_primary_key(pool, tracking_table_name, &["version", "is_baseline"]).await?;
Ok(())
}
struct RegisterStart<'a> {
version: u64,
description: &'a str,
checksum: &'a str,
sections: &'a [(i32, crate::migration::section_parser::MigrationSection)],
satisfied: &'a [(i32, crate::migration::section_parser::MigrationSection)],
is_baseline: bool,
on_conflict_ignore: bool,
}
async fn register_start(
pool: &PgPool,
tracking_table: &TrackingTable,
params: RegisterStart<'_>,
) -> Result<()> {
let RegisterStart {
version,
description,
checksum,
sections,
satisfied,
is_baseline,
on_conflict_ignore,
} = params;
let tracking_table_name = format_tracking_table_name(tracking_table)?;
let sections_table = section_tracking::format_sections_table_name(tracking_table);
let kind = if is_baseline { "baseline" } else { "migration" };
let conflict = if on_conflict_ignore {
" ON CONFLICT (version, is_baseline) DO NOTHING"
} else {
""
};
let mut tx = pool.begin().await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"INSERT INTO {} (version, description, checksum, is_baseline) VALUES ($1, $2, $3, $4){}",
tracking_table_name, conflict
)))
.bind(version_to_db(version)?)
.bind(description)
.bind(checksum)
.bind(is_baseline)
.execute(&mut *tx)
.await
.with_context(|| format!("Failed to record {} {} in tracking table", kind, version))?;
for (order, section) in sections {
section_tracking::insert_pending_section(
&mut *tx,
§ions_table,
version,
is_baseline,
*order,
section,
)
.await?;
}
for (order, section) in satisfied {
section_tracking::insert_satisfied_section(
&mut tx,
§ions_table,
version,
is_baseline,
*order,
section,
)
.await?;
}
tx.commit()
.await
.with_context(|| format!("Failed to register {} {} start", kind, version))?;
Ok(())
}
pub async fn register_baseline_start(
pool: &PgPool,
tracking_table: &TrackingTable,
version: u64,
description: &str,
checksum: &str,
sections: &[(i32, crate::migration::section_parser::MigrationSection)],
) -> Result<()> {
ensure_tracking_table_exists(pool, tracking_table).await?;
register_start(
pool,
tracking_table,
RegisterStart {
version,
description,
checksum,
sections,
satisfied: &[],
is_baseline: true,
on_conflict_ignore: true,
},
)
.await
}
pub async fn register_migration_start(
pool: &PgPool,
tracking_table: &TrackingTable,
version: u64,
description: &str,
checksum: &str,
sections: &[(i32, crate::migration::section_parser::MigrationSection)],
satisfied: &[(i32, crate::migration::section_parser::MigrationSection)],
) -> Result<()> {
register_start(
pool,
tracking_table,
RegisterStart {
version,
description,
checksum,
sections,
satisfied,
is_baseline: false,
on_conflict_ignore: false,
},
)
.await
}
pub fn calculate_checksum(content: &str) -> String {
format!("{:x}", md5::compute(content))
}
pub async fn update_stored_file_checksum(
pool: &PgPool,
tracking_table: &TrackingTable,
version: u64,
is_baseline: bool,
checksum: &str,
) -> Result<()> {
let tracking_table_name = format_tracking_table_name(tracking_table)?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"UPDATE {} SET checksum = $1 WHERE version = $2 AND is_baseline = $3",
tracking_table_name
)))
.bind(checksum)
.bind(version_to_db(version)?)
.bind(is_baseline)
.execute(pool)
.await
.with_context(|| format!("Failed to update stored checksum for version {}", version))?;
Ok(())
}