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

View File

@@ -119,7 +119,7 @@ fn admin_wrapped_state_owns_billing_capabilities() {
let admin_request =
read_workspace_module_tree("apps/aether-gateway/src/handlers/admin/request/mod.rs");
for pattern in [
"pub(crate) fn has_postgres_pool(&self) -> bool",
"pub(crate) fn has_wallet_data_writer(&self) -> bool",
"pub(crate) async fn list_admin_billing_collectors(",
"pub(crate) async fn read_admin_billing_collector(",
"pub(crate) async fn create_admin_billing_collector(",

View File

@@ -4701,6 +4701,7 @@ fn retired_api_format_occurrences_are_whitelisted() {
"crates/aether-ai-formats/src/protocol/matrix.rs",
"crates/aether-ai-formats/src/protocol/registry.rs",
"crates/aether-data/src/migrate.rs",
"crates/aether-data/src/lifecycle/migrate/tests.rs",
"crates/aether-usage-runtime/src/report.rs",
"frontend/src/api/endpoints/types/__tests__/api-format.spec.ts",
];

View File

@@ -23,6 +23,10 @@ pub(super) fn assert_no_sqlx_queries(root_relative_path: &str) {
let patterns = [
"sqlx::query(",
"sqlx::query_scalar",
"sqlx::postgres::PgRow",
"sqlx::Row",
"PostgresPoolFactory",
"PostgresPool",
"query_scalar::<",
"QueryBuilder<",
];

View File

@@ -1,5 +1,9 @@
use super::*;
fn production_source(source: &str) -> &str {
source.split("#[cfg(test)]").next().unwrap_or(source)
}
#[test]
fn handlers_do_not_inline_sql_queries() {
assert_no_sqlx_queries("src/handlers");
@@ -10,11 +14,157 @@ fn gateway_runtime_does_not_inline_sql_queries() {
assert_no_sqlx_queries("src/state/runtime");
}
#[test]
fn aether_data_bootstrap_snapshot_is_built_from_schema_sources() {
let build_rs = read_workspace_file("crates/aether-data/build.rs");
assert!(
build_rs.contains("schema/bootstrap/postgres/manifest.txt"),
"build.rs should source the bootstrap snapshot from schema/bootstrap/postgres"
);
let compose_schema = read_workspace_file("crates/aether-data/schema/compose_schema.sh");
assert!(
compose_schema.contains("check_bootstrap_sources"),
"compose_schema.sh should still validate bootstrap source fragments"
);
assert!(
!compose_schema.contains("bootstrap/postgres/20260413020000_empty_database_snapshot.sql"),
"compose_schema.sh should not depend on the outer bootstrap artifact anymore"
);
let bootstrap = read_workspace_file("crates/aether-data/src/lifecycle/bootstrap/postgres.rs");
assert!(
bootstrap.contains("include_str!(concat!(env!(\"OUT_DIR\"), \"/empty_database_snapshot.sql\"))"),
"lifecycle/bootstrap/postgres.rs should embed the generated bootstrap snapshot from OUT_DIR"
);
assert!(
!bootstrap
.contains("../../../bootstrap/postgres/20260413020000_empty_database_snapshot.sql"),
"lifecycle/bootstrap/postgres.rs should not read the outer bootstrap artifact directly"
);
let provider_catalog =
read_workspace_file("crates/aether-data/src/repository/provider_catalog/postgres.rs");
assert!(
!provider_catalog.contains("../../../bootstrap/postgres/20260413020000_empty_database_snapshot.sql"),
"provider_catalog tests should use the shared bootstrap snapshot constant instead of the outer bootstrap artifact"
);
}
#[test]
fn aether_data_backend_pool_modules_do_not_own_maintenance_sql() {
for path in [
"crates/aether-data/src/backend/postgres.rs",
"crates/aether-data/src/backend/mysql.rs",
"crates/aether-data/src/backend/sqlite.rs",
] {
let source = read_workspace_file(path);
let production = production_source(&source);
for forbidden in [
"run_table_maintenance(",
"aggregate_wallet_daily_usage(",
"aggregate_stats_hourly(",
"aggregate_stats_daily(",
"find_system_config_value(",
"list_system_config_entries(",
"upsert_system_config_entry(",
"read_admin_system_stats(",
"sqlx::query(",
"sqlx::query_scalar",
"sqlx::raw_sql(",
] {
assert!(
!production.contains(forbidden),
"{path} should stay focused on pool and repository construction instead of owning maintenance SQL via {forbidden}"
);
}
}
let maintenance = read_workspace_file("crates/aether-data/src/backend/maintenance.rs");
for pattern in [
"Self::Postgres(postgres) => postgres.run_table_maintenance(table_names).await",
"Self::Mysql(mysql) => mysql.run_table_maintenance(table_names).await",
"Self::Sqlite(sqlite) => sqlite.run_table_maintenance(table_names).await",
"Self::Postgres(postgres) => postgres.aggregate_wallet_daily_usage(input).await",
"Self::Mysql(mysql) => mysql.aggregate_wallet_daily_usage(input).await",
"Self::Sqlite(sqlite) => sqlite.aggregate_wallet_daily_usage(input).await",
"Self::Postgres(postgres) => postgres.aggregate_stats_hourly(input).await",
"Self::Mysql(mysql) => mysql.aggregate_stats_hourly(input).await",
"Self::Sqlite(sqlite) => sqlite.aggregate_stats_hourly(input).await",
"Self::Postgres(postgres) => postgres.aggregate_stats_daily(input).await",
"Self::Mysql(mysql) => mysql.aggregate_stats_daily(input).await",
"Self::Sqlite(sqlite) => sqlite.aggregate_stats_daily(input).await",
] {
assert!(
maintenance.contains(pattern),
"backend/maintenance.rs should own SQL-driver maintenance dispatch {pattern}"
);
}
}
#[test]
fn testkit_does_not_copy_aether_business_schema_sql() {
let owner_relay_baseline =
read_workspace_file("crates/aether-testkit/src/bin/multi_instance_owner_relay_baseline.rs");
for forbidden in [
"CREATE TYPE proxynodestatus",
"CREATE TABLE IF NOT EXISTS system_configs",
"CREATE TABLE IF NOT EXISTS proxy_nodes",
"CREATE TABLE IF NOT EXISTS proxy_node_events",
"PgConnection::connect",
"sqlx::{Connection, Executor, PgConnection}",
] {
assert!(
!owner_relay_baseline.contains(forbidden),
"owner relay baseline should use aether-data schema bootstrap instead of copying business schema SQL via {forbidden}"
);
}
assert!(
owner_relay_baseline.contains("prepare_aether_postgres_schema(&postgres_url).await?"),
"owner relay baseline should prepare business schema through aether-testkit's aether-data helper"
);
let postgres_testkit = read_workspace_file("crates/aether-testkit/src/postgres.rs");
for required in [
"pub async fn prepare_aether_postgres_schema",
"DataBackends::from_config",
".prepare_database_for_startup()",
".run_database_migrations()",
] {
assert!(
postgres_testkit.contains(required),
"testkit Postgres helper should delegate Aether schema setup to aether-data via {required}"
);
}
}
#[test]
fn gateway_main_keeps_database_export_import_driver_selection_in_data_layer() {
let main_rs = read_workspace_file("apps/aether-gateway/src/main.rs");
for forbidden in [
"PostgresPoolFactory",
"MysqlPoolFactory",
"SqlitePoolFactory",
"to_postgres_config()",
] {
assert!(
!main_rs.contains(forbidden),
"main.rs should delegate database export/import driver selection to aether-data instead of {forbidden}"
);
}
for required in ["export_database_jsonl", "import_database_jsonl"] {
assert!(
main_rs.contains(required),
"main.rs should use aether-data {required}"
);
}
}
#[test]
fn wallet_repository_does_not_reexport_settlement_types() {
let wallet_mod = read_workspace_file("crates/aether-data/src/repository/wallet/mod.rs");
let wallet_types = read_workspace_file("crates/aether-data/src/repository/wallet/types.rs");
let wallet_sql = read_workspace_file("crates/aether-data/src/repository/wallet/sql.rs");
let wallet_sql = read_workspace_file("crates/aether-data/src/repository/wallet/postgres.rs");
let wallet_memory = read_workspace_file("crates/aether-data/src/repository/wallet/memory.rs");
assert!(
@@ -35,7 +185,7 @@ fn wallet_repository_does_not_reexport_settlement_types() {
);
assert!(
!wallet_sql.contains("impl SettlementWriteRepository"),
"wallet/sql.rs should not implement SettlementWriteRepository"
"wallet/postgres.rs should not implement SettlementWriteRepository"
);
assert!(
!wallet_memory.contains("impl SettlementWriteRepository"),
@@ -57,8 +207,9 @@ fn gateway_system_config_types_are_owned_by_aether_data() {
let state_core = read_workspace_file("apps/aether-gateway/src/data/state/core.rs");
for pattern in [
"backend.list_system_config_entries().await",
"upsert_system_config_entry(key, value, description)",
"backends.list_system_config_entries().await",
".upsert_system_config_entry(key, value, description)",
"backends.read_admin_system_stats().await",
"AdminSystemStats::default()",
] {
assert!(
@@ -66,6 +217,17 @@ fn gateway_system_config_types_are_owned_by_aether_data() {
"data/state/core.rs should use shared system DTO path {pattern}"
);
}
let data_backends = read_workspace_file("crates/aether-data/src/backend/maintenance.rs");
for pattern in [
"postgres.list_system_config_entries().await",
"mysql.list_system_config_entries().await",
"sqlite.list_system_config_entries().await",
] {
assert!(
data_backends.contains(pattern),
"aether-data backends should own driver-specific system config dispatch {pattern}"
);
}
for pattern in [
"|(key, value, description, updated_at_unix_secs)|",
"Ok((0, 0, 0, 0))",
@@ -180,6 +342,7 @@ fn gateway_auth_data_layer_does_not_keep_ldap_row_wrapper() {
"fn map_ldap_user_auth_row(",
"Result<Option<StoredLdapAuthUserRow>, DataLayerError>",
"existing.user.",
"map_user_auth_row(row)",
] {
assert!(
!gateway_auth_state.contains(pattern),
@@ -187,13 +350,14 @@ fn gateway_auth_data_layer_does_not_keep_ldap_row_wrapper() {
);
}
let user_sql = read_workspace_file("crates/aether-data/src/repository/users/postgres.rs");
for pattern in [
"Result<Option<StoredUserAuthRecord>, DataLayerError>",
"return map_user_auth_row(row).map(Some);",
] {
assert!(
gateway_auth_state.contains(pattern),
"data/state/auth.rs should use shared user auth record directly via {pattern}"
user_sql.contains(pattern),
"aether-data user repository should use shared user auth record directly via {pattern}"
);
}
}

View File

@@ -909,7 +909,7 @@ async fn gateway_returns_conflict_for_admin_create_user_when_writer_unavailable(
let mut state = AppState::new().expect("gateway should build");
state =
state.with_data_state_for_tests(crate::data::GatewayDataState::with_user_reader_for_tests(
Arc::new(InMemoryUserReadRepository::seed_auth_users(Vec::new())),
Arc::new(InMemoryUserReadRepository::seed_auth_users(Vec::new()).read_only()),
));
state.auth_user_store = None;
state.auth_wallet_store = None;
@@ -1032,9 +1032,9 @@ async fn gateway_returns_conflict_for_admin_update_user_when_writer_unavailable(
}
}));
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
sample_admin_user("user-1"),
]));
let user_repository = Arc::new(
InMemoryUserReadRepository::seed_auth_users(vec![sample_admin_user("user-1")]).read_only(),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let mut state = AppState::new()

View File

@@ -4215,7 +4215,8 @@ async fn gateway_returns_service_unavailable_for_users_me_detail_update_without_
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let user_repository =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]).read_only());
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
@@ -4365,7 +4366,8 @@ async fn gateway_returns_service_unavailable_for_users_me_password_change_withou
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let user_repository =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]).read_only());
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
@@ -4731,7 +4733,8 @@ async fn gateway_returns_service_unavailable_for_users_me_preferences_update_wit
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let user_repository =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]).read_only());
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =
@@ -8210,7 +8213,8 @@ async fn gateway_returns_service_unavailable_for_users_me_model_capabilities_upd
]),
now + chrono::Duration::hours(1),
);
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]));
let user_repository =
Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![user]).read_only());
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
start_auth_gateway_with_builder(|| {
let data_state =