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:
fawney19
2026-05-05 18:27:36 +08:00
parent 099653f732
commit fce7e959e5
372 changed files with 86217 additions and 21160 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
//! Empty-database bootstrap workflows.
//!
//! Bootstrap is separate from migration execution: it prepares a fresh database
//! from a squashed snapshot, then stamps the migrations covered by that
//! snapshot so normal migration runners only apply later changes.
pub(crate) mod postgres;

View File

@@ -0,0 +1,128 @@
use sqlx::{
migrate::{Migrate, MigrateError, Migration, Migrator},
query, query_scalar, Connection, PgConnection,
};
use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260502000000;
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name <> '_sqlx_migrations'
"#;
const AETHER_SCHEMA_FOOTPRINT_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name IN (
'api_key_provider_mappings',
'auth_modules',
'gemini_file_mappings',
'global_models',
'oauth_providers',
'provider_api_keys',
'proxy_nodes',
'usage_routing_snapshots',
'usage_settlement_snapshots'
)
"#;
const INSERT_APPLIED_MIGRATION_SQL: &str = r#"
INSERT INTO _sqlx_migrations (
version,
description,
success,
checksum,
execution_time
) VALUES (
$1,
$2,
TRUE,
$3,
0
)
ON CONFLICT (version) DO NOTHING
"#;
pub(crate) async fn apply_snapshot_if_empty(
conn: &mut PgConnection,
migrator: &'static Migrator,
) -> Result<(), MigrateError> {
if !should_apply_snapshot(conn).await? {
return Ok(());
}
let migrations = snapshot_migrations(migrator)?;
info!(
cutoff_version = EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION,
stamped_migrations = migrations.len(),
"bootstrapping empty database from empty_database_snapshot"
);
let mut tx = conn.begin().await?;
sqlx::raw_sql(EMPTY_DATABASE_SNAPSHOT_SQL)
.execute(&mut *tx)
.await?;
for migration in migrations {
query(INSERT_APPLIED_MIGRATION_SQL)
.bind(migration.version)
.bind(migration.description.as_ref())
.bind(migration.checksum.as_ref())
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub(crate) async fn should_apply_snapshot(conn: &mut PgConnection) -> Result<bool, MigrateError> {
let applied_migrations = conn.list_applied_migrations().await?;
if !applied_migrations.is_empty() {
return Ok(false);
}
let public_table_count: i64 = query_scalar(PUBLIC_BASE_TABLE_COUNT_SQL)
.fetch_one(&mut *conn)
.await?;
if public_table_count == 0 {
return Ok(true);
}
let aether_footprint_table_count: i64 = query_scalar(AETHER_SCHEMA_FOOTPRINT_TABLE_COUNT_SQL)
.fetch_one(&mut *conn)
.await?;
if aether_footprint_table_count == 0 {
info!(
public_table_count,
"no Aether schema footprint detected; allowing empty database snapshot bootstrap despite pre-existing public tables"
);
return Ok(true);
}
Ok(false)
}
pub(crate) fn snapshot_migrations(
migrator: &'static Migrator,
) -> Result<Vec<&'static Migration>, MigrateError> {
let migrations = migrator
.iter()
.filter(|migration| migration.migration_type.is_up_migration())
.filter(|migration| migration.version <= EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION)
.collect::<Vec<_>>();
if migrations.is_empty() {
return Err(MigrateError::Source(Box::new(std::io::Error::other(
"empty database snapshot cutoff does not match any embedded migrations",
))));
}
Ok(migrations)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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(())
}

View File

@@ -0,0 +1,36 @@
use sqlx::{
migrate::{Migrate, MigrateError, Migrator},
MySqlPool,
};
use super::{
is_missing_sqlx_migrations_table_error, pending_migrations_from_applied_for,
PendingMigrationInfo,
};
pub(super) static MIGRATOR: Migrator = sqlx::migrate!("./migrations/mysql");
pub async fn run_migrations(pool: &MySqlPool) -> Result<(), MigrateError> {
MIGRATOR.run(pool).await
}
pub async fn pending_migrations(
pool: &MySqlPool,
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
let mut conn = pool.acquire().await?;
let applied_migrations = match conn.list_applied_migrations().await {
Ok(applied_migrations) => applied_migrations,
Err(err) if is_missing_sqlx_migrations_table_error(&err) => Vec::new(),
Err(err) => return Err(err),
};
Ok(pending_migrations_from_applied_for(
&MIGRATOR,
&applied_migrations,
))
}
pub async fn prepare_database_for_startup(
pool: &MySqlPool,
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
pending_migrations(pool).await
}

View File

@@ -0,0 +1,36 @@
use sqlx::{
migrate::{Migrate, MigrateError, Migrator},
SqlitePool,
};
use super::{
is_missing_sqlx_migrations_table_error, pending_migrations_from_applied_for,
PendingMigrationInfo,
};
pub(super) static MIGRATOR: Migrator = sqlx::migrate!("./migrations/sqlite");
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), MigrateError> {
MIGRATOR.run(pool).await
}
pub async fn pending_migrations(
pool: &SqlitePool,
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
let mut conn = pool.acquire().await?;
let applied_migrations = match conn.list_applied_migrations().await {
Ok(applied_migrations) => applied_migrations,
Err(err) if is_missing_sqlx_migrations_table_error(&err) => Vec::new(),
Err(err) => return Err(err),
};
Ok(pending_migrations_from_applied_for(
&MIGRATOR,
&applied_migrations,
))
}
pub async fn prepare_database_for_startup(
pool: &SqlitePool,
) -> Result<Vec<PendingMigrationInfo>, MigrateError> {
pending_migrations(pool).await
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
//! Database lifecycle workflows.
//!
//! Runtime request paths should not depend on this module directly except at
//! process startup or explicit maintenance/export commands.
pub mod backfill;
pub(crate) mod bootstrap;
pub mod export;
pub mod migrate;