mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
Add multi-database data layer
Introduce aether-data-schema and driver-specific schema generation for Postgres, MySQL, and SQLite. Split data backends, lifecycle, repositories, and gateway runtime integration across database drivers. Verified with cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
//! Runtime database migration entry points.
|
||||
//!
|
||||
//! Postgres uses the empty-database snapshot bootstrap before checking normal
|
||||
//! migrations. The snapshot logic lives under `lifecycle::bootstrap` so this
|
||||
//! module remains focused on migration execution and pending-migration
|
||||
//! reporting.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use sqlx::{
|
||||
migrate::{Migrate, MigrateError, Migrator},
|
||||
query_scalar, PgConnection, PgPool,
|
||||
};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
mod mysql;
|
||||
mod sqlite;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
static POSTGRES_MIGRATOR: Migrator = sqlx::migrate!("./migrations/postgres");
|
||||
const MIGRATIONS_TABLE_EXISTS_SQL: &str =
|
||||
"SELECT to_regclass('public._sqlx_migrations') IS NOT NULL";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingMigrationInfo {
|
||||
pub version: i64,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Run all pending Postgres migrations embedded at compile time from `migrations/postgres/`.
|
||||
pub async fn run_migrations(pool: &PgPool) -> Result<(), MigrateError> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
conn.lock().await?;
|
||||
}
|
||||
|
||||
let result = run_migrations_locked(&mut conn).await;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
match conn.unlock().await {
|
||||
Ok(()) => {}
|
||||
Err(unlock_error) if result.is_ok() => return Err(unlock_error),
|
||||
Err(unlock_error) => {
|
||||
warn!(
|
||||
error = %unlock_error,
|
||||
"database migration lock release failed after migration error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn pending_migrations(pool: &PgPool) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
pending_migrations_locked(&mut conn).await
|
||||
}
|
||||
|
||||
pub async fn run_mysql_migrations(pool: &sqlx::MySqlPool) -> Result<(), MigrateError> {
|
||||
mysql::run_migrations(pool).await
|
||||
}
|
||||
|
||||
pub async fn pending_mysql_migrations(
|
||||
pool: &sqlx::MySqlPool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
mysql::pending_migrations(pool).await
|
||||
}
|
||||
|
||||
pub async fn prepare_mysql_database_for_startup(
|
||||
pool: &sqlx::MySqlPool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
mysql::prepare_database_for_startup(pool).await
|
||||
}
|
||||
|
||||
pub async fn run_sqlite_migrations(pool: &sqlx::SqlitePool) -> Result<(), MigrateError> {
|
||||
sqlite::run_migrations(pool).await
|
||||
}
|
||||
|
||||
pub async fn pending_sqlite_migrations(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
sqlite::pending_migrations(pool).await
|
||||
}
|
||||
|
||||
pub async fn prepare_sqlite_database_for_startup(
|
||||
pool: &sqlx::SqlitePool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
sqlite::prepare_database_for_startup(pool).await
|
||||
}
|
||||
|
||||
pub async fn prepare_database_for_startup(
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
conn.lock().await?;
|
||||
}
|
||||
|
||||
let result = prepare_database_for_startup_locked(&mut conn).await;
|
||||
|
||||
if POSTGRES_MIGRATOR.locking {
|
||||
match conn.unlock().await {
|
||||
Ok(()) => {}
|
||||
Err(unlock_error) if result.is_ok() => return Err(unlock_error),
|
||||
Err(unlock_error) => {
|
||||
warn!(
|
||||
error = %unlock_error,
|
||||
"database migration lock release failed after startup preparation error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn is_missing_sqlx_migrations_table_error(err: &MigrateError) -> bool {
|
||||
let message = err.to_string().to_ascii_lowercase();
|
||||
message.contains("_sqlx_migrations")
|
||||
&& (message.contains("no such table")
|
||||
|| message.contains("doesn't exist")
|
||||
|| message.contains("does not exist")
|
||||
|| message.contains("unknown table"))
|
||||
}
|
||||
|
||||
async fn run_migrations_locked(conn: &mut PgConnection) -> Result<(), MigrateError> {
|
||||
conn.ensure_migrations_table().await?;
|
||||
crate::lifecycle::bootstrap::postgres::apply_snapshot_if_empty(conn, &POSTGRES_MIGRATOR)
|
||||
.await?;
|
||||
|
||||
if let Some(version) = conn.dirty_version().await? {
|
||||
error!(version, "database migration state is dirty");
|
||||
return Err(MigrateError::Dirty(version));
|
||||
}
|
||||
|
||||
let applied_migrations = conn.list_applied_migrations().await?;
|
||||
validate_applied_migrations(&applied_migrations)?;
|
||||
|
||||
let known_versions: HashSet<_> = POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.map(|migration| migration.version)
|
||||
.collect();
|
||||
let applied_migrations_by_version: HashMap<_, _> = applied_migrations
|
||||
.into_iter()
|
||||
.map(|migration| (migration.version, migration))
|
||||
.collect();
|
||||
|
||||
let pending_migrations: Vec<_> = POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.filter(|migration| !applied_migrations_by_version.contains_key(&migration.version))
|
||||
.collect();
|
||||
|
||||
let total_migrations = known_versions.len();
|
||||
let applied_count = total_migrations.saturating_sub(pending_migrations.len());
|
||||
|
||||
if pending_migrations.is_empty() {
|
||||
info!(
|
||||
total_migrations,
|
||||
applied_migrations = applied_count,
|
||||
pending_migrations = 0,
|
||||
"database migrations already up to date"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
total_migrations,
|
||||
applied_migrations = applied_count,
|
||||
pending_migrations = pending_migrations.len(),
|
||||
"database migrations pending"
|
||||
);
|
||||
|
||||
for (index, migration) in pending_migrations.iter().enumerate() {
|
||||
let current = index + 1;
|
||||
let total = pending_migrations.len();
|
||||
|
||||
info!(
|
||||
current,
|
||||
total,
|
||||
version = migration.version,
|
||||
description = %migration.description,
|
||||
"applying database migration"
|
||||
);
|
||||
|
||||
let elapsed = conn.apply(migration).await?;
|
||||
|
||||
info!(
|
||||
current,
|
||||
total,
|
||||
version = migration.version,
|
||||
description = %migration.description,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
"applied database migration"
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
total_migrations,
|
||||
applied_migrations = total_migrations,
|
||||
pending_migrations = 0,
|
||||
"database migrations complete"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prepare_database_for_startup_locked(
|
||||
conn: &mut PgConnection,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
conn.ensure_migrations_table().await?;
|
||||
crate::lifecycle::bootstrap::postgres::apply_snapshot_if_empty(conn, &POSTGRES_MIGRATOR)
|
||||
.await?;
|
||||
pending_migrations_locked(conn).await
|
||||
}
|
||||
|
||||
async fn pending_migrations_locked(
|
||||
conn: &mut PgConnection,
|
||||
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
|
||||
if !migrations_table_exists(conn).await? {
|
||||
return Ok(all_up_migrations());
|
||||
}
|
||||
|
||||
if let Some(version) = conn.dirty_version().await? {
|
||||
error!(version, "database migration state is dirty");
|
||||
return Err(MigrateError::Dirty(version));
|
||||
}
|
||||
|
||||
let applied_migrations = conn.list_applied_migrations().await?;
|
||||
validate_applied_migrations(&applied_migrations)?;
|
||||
Ok(pending_migrations_from_applied(&applied_migrations))
|
||||
}
|
||||
|
||||
async fn migrations_table_exists(conn: &mut PgConnection) -> Result<bool, MigrateError> {
|
||||
let exists: bool = query_scalar(MIGRATIONS_TABLE_EXISTS_SQL)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
fn all_up_migrations() -> Vec<PendingMigrationInfo> {
|
||||
POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.map(|migration| PendingMigrationInfo {
|
||||
version: migration.version,
|
||||
description: migration.description.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pending_migrations_from_applied(
|
||||
applied_migrations: &[sqlx::migrate::AppliedMigration],
|
||||
) -> Vec<PendingMigrationInfo> {
|
||||
pending_migrations_from_applied_for(&POSTGRES_MIGRATOR, applied_migrations)
|
||||
}
|
||||
|
||||
fn pending_migrations_from_applied_for(
|
||||
migrator: &'static Migrator,
|
||||
applied_migrations: &[sqlx::migrate::AppliedMigration],
|
||||
) -> Vec<PendingMigrationInfo> {
|
||||
let applied_versions: HashSet<_> = applied_migrations
|
||||
.iter()
|
||||
.map(|migration| migration.version)
|
||||
.collect();
|
||||
|
||||
migrator
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
.filter(|migration| !applied_versions.contains(&migration.version))
|
||||
.map(|migration| PendingMigrationInfo {
|
||||
version: migration.version,
|
||||
description: migration.description.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_applied_migrations(
|
||||
applied_migrations: &[sqlx::migrate::AppliedMigration],
|
||||
) -> Result<(), MigrateError> {
|
||||
if POSTGRES_MIGRATOR.ignore_missing {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let known_versions: HashSet<_> = POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.map(|migration| migration.version)
|
||||
.collect();
|
||||
|
||||
for applied_migration in applied_migrations {
|
||||
if !known_versions.contains(&applied_migration.version) {
|
||||
error!(
|
||||
version = applied_migration.version,
|
||||
"applied database migration is missing from embedded migrations"
|
||||
);
|
||||
return Err(MigrateError::VersionMissing(applied_migration.version));
|
||||
}
|
||||
}
|
||||
|
||||
// Checksum drift is reported as a warning only. Strict enforcement makes
|
||||
// harmless edits (comment tweaks, whitespace, metadata fixes) impossible
|
||||
// without also touching every environment's migration history table. We
|
||||
// match by version alone — sqlx still skips already-applied migrations so
|
||||
// edited files will not re-run, they are merely allowed to exist.
|
||||
for migration in POSTGRES_MIGRATOR
|
||||
.iter()
|
||||
.filter(|migration| migration.migration_type.is_up_migration())
|
||||
{
|
||||
if let Some(applied_migration) = applied_migrations
|
||||
.iter()
|
||||
.find(|applied_migration| applied_migration.version == migration.version)
|
||||
{
|
||||
if migration.checksum != applied_migration.checksum {
|
||||
warn!(
|
||||
version = migration.version,
|
||||
description = %migration.description,
|
||||
"database migration checksum mismatch (ignored: version-only validation)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user