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
+174 -1
View File
@@ -8,8 +8,11 @@ use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelect
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::repository::usage::InMemoryUsageReadRepository;
use aether_data::repository::users::{
InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserPreferenceRecord,
};
use aether_data::repository::video_tasks::InMemoryVideoTaskRepository;
use aether_data::DataLayerError;
use aether_data::{DataLayerError, DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig};
use aether_data_contracts::repository::candidate_selection::{
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
};
@@ -144,6 +147,176 @@ async fn app_state_wires_gateway_data_state_from_config() {
assert!(state.data.has_video_task_reader());
}
#[tokio::test]
async fn app_state_prepares_sqlite_database_startup() -> Result<(), Box<dyn std::error::Error>> {
let mut pool = SqlPoolConfig::default();
pool.min_connections = 0;
pool.max_connections = 1;
let database = SqlDatabaseConfig::new(DatabaseDriver::Sqlite, "sqlite::memory:", pool)?;
let state =
AppState::new()?.with_data_config(GatewayDataConfig::from_database_config(database))?;
let pending = state
.prepare_database_for_startup()
.await?
.expect("sqlite database should expose migration state");
assert!(
!pending.is_empty(),
"fresh sqlite gateway databases should report pending migrations"
);
assert!(
state.run_database_migrations().await?,
"sqlite gateway database should run migrations"
);
let pending = state
.prepare_database_for_startup()
.await?
.expect("sqlite database should expose migration state");
assert!(
pending.is_empty(),
"sqlite gateway databases should be current after migrations"
);
Ok(())
}
#[tokio::test]
async fn data_state_checks_user_uniqueness_through_user_reader() {
let user = StoredUserAuthRecord::new(
"user-1".to_string(),
Some("[email protected]".to_string()),
true,
"alice".to_string(),
Some("hash".to_string()),
"user".to_string(),
"local".to_string(),
None,
None,
None,
true,
false,
None,
None,
)
.expect("auth user should build");
let admin = StoredUserAuthRecord::new(
"admin-1".to_string(),
Some("[email protected]".to_string()),
true,
"admin".to_string(),
Some(format!("$2b$12${}", "a".repeat(53))),
"admin".to_string(),
"local".to_string(),
None,
None,
None,
true,
false,
None,
None,
)
.expect("admin user should build");
let state = GatewayDataState::with_user_reader_for_tests(Arc::new(
InMemoryUserReadRepository::seed_auth_users(vec![user, admin]),
));
assert!(state
.is_other_user_auth_email_taken("[email protected]", "other-user")
.await
.expect("email uniqueness should check"));
assert!(!state
.is_other_user_auth_email_taken("[email protected]", "user-1")
.await
.expect("same user email should not be taken"));
assert!(!state
.is_other_user_auth_email_taken("alice", "other-user")
.await
.expect("email lookup should not match username"));
assert!(state
.is_other_user_auth_username_taken("alice", "other-user")
.await
.expect("username uniqueness should check"));
assert_eq!(
state
.count_active_admin_users()
.await
.expect("active admin count should check"),
1
);
assert_eq!(
state
.count_active_local_admin_users_with_valid_password()
.await
.expect("valid local admin count should check"),
1
);
let preferences = StoredUserPreferenceRecord {
user_id: "user-1".to_string(),
avatar_url: Some("https://example.test/avatar.png".to_string()),
bio: Some("hello".to_string()),
default_provider_id: None,
default_provider_name: None,
theme: "dark".to_string(),
language: "en-US".to_string(),
timezone: "UTC".to_string(),
email_notifications: false,
usage_alerts: true,
announcement_notifications: false,
};
assert_eq!(
state
.write_user_preferences(&preferences)
.await
.expect("preferences should write through repository"),
Some(preferences.clone())
);
assert_eq!(
state
.read_user_preferences("user-1")
.await
.expect("preferences should read through repository"),
Some(preferences)
);
}
#[tokio::test]
async fn data_state_finds_active_provider_name_through_catalog_reader() {
let active = StoredProviderCatalogProvider::new(
"provider-1".to_string(),
"Provider One".to_string(),
None,
"openai".to_string(),
)
.expect("provider should build");
let inactive = StoredProviderCatalogProvider::new(
"provider-2".to_string(),
"Provider Two".to_string(),
None,
"openai".to_string(),
)
.expect("provider should build")
.with_transport_fields(false, false, false, None, None, None, None, None, None);
let state = GatewayDataState::with_provider_catalog_reader_for_tests(Arc::new(
InMemoryProviderCatalogReadRepository::seed(vec![active, inactive], Vec::new(), Vec::new()),
));
assert_eq!(
state
.find_active_provider_name("provider-1")
.await
.expect("provider lookup should succeed"),
Some("Provider One".to_string())
);
assert_eq!(
state
.find_active_provider_name("provider-2")
.await
.expect("inactive provider lookup should succeed"),
None
);
}
fn sample_auth_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
StoredAuthApiKeySnapshot::new(
user_id.to_string(),