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

@@ -1,10 +1,15 @@
mod memory;
mod sql;
mod mysql;
mod postgres;
mod sqlite;
mod types;
pub use memory::InMemoryUserReadRepository;
pub use sql::SqlxUserReadRepository;
pub use mysql::MysqlUserReadRepository;
pub use postgres::SqlxUserReadRepository;
pub use sqlite::SqliteUserReadRepository;
pub use types::{
StoredUserAuthRecord, StoredUserExportRow, StoredUserPreferenceRecord, StoredUserSessionRecord,
StoredUserSummary, UserExportListQuery, UserExportSummary, UserReadRepository,
StoredUserAuthRecord, StoredUserExportRow, StoredUserOAuthLinkSummary,
StoredUserPreferenceRecord, StoredUserSessionRecord, StoredUserSummary, UserExportListQuery,
UserExportSummary, UserReadRepository,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,473 +0,0 @@
use async_trait::async_trait;
use futures_util::TryStreamExt;
use sqlx::{PgPool, Postgres, QueryBuilder, Row};
use super::types::{
StoredUserAuthRecord, StoredUserExportRow, StoredUserSummary, UserExportListQuery,
UserExportSummary, UserReadRepository,
};
use crate::{error::SqlxResultExt, DataLayerError};
const LIST_USERS_BY_IDS_SQL: &str = r#"
SELECT
id,
username,
email,
role::text AS role,
is_active,
is_deleted
FROM users
WHERE id = ANY($1::text[])
ORDER BY id ASC
"#;
const LIST_USERS_BY_USERNAME_SEARCH_SQL: &str = r#"
SELECT
id,
username,
email,
role::text AS role,
is_active,
is_deleted
FROM users
WHERE is_deleted IS FALSE
AND LOWER(username) LIKE $1
ORDER BY id ASC
"#;
const LIST_NON_ADMIN_EXPORT_USERS_SQL: &str = r#"
SELECT
id,
email,
email_verified,
username,
password_hash,
role::text AS role,
auth_source::text AS auth_source,
allowed_providers,
allowed_api_formats,
allowed_models,
rate_limit,
model_capability_settings,
is_active
FROM users
WHERE is_deleted IS FALSE
AND role::text != 'admin'
ORDER BY id ASC
"#;
const LIST_EXPORT_USERS_SQL: &str = r#"
SELECT
id,
email,
email_verified,
username,
password_hash,
role::text AS role,
auth_source::text AS auth_source,
allowed_providers,
allowed_api_formats,
allowed_models,
rate_limit,
model_capability_settings,
is_active
FROM users
WHERE is_deleted IS FALSE
ORDER BY id ASC
"#;
const LIST_EXPORT_USERS_PAGE_PREFIX: &str = r#"
SELECT
id,
email,
email_verified,
username,
password_hash,
role::text AS role,
auth_source::text AS auth_source,
allowed_providers,
allowed_api_formats,
allowed_models,
rate_limit,
model_capability_settings,
is_active
FROM users
WHERE is_deleted IS FALSE
"#;
const SUMMARIZE_EXPORT_USERS_SQL: &str = r#"
SELECT
COUNT(*)::BIGINT AS total,
COUNT(*) FILTER (WHERE is_active = TRUE)::BIGINT AS active
FROM users
WHERE is_deleted IS FALSE
"#;
const FIND_EXPORT_USER_BY_ID_SQL: &str = r#"
SELECT
id,
email,
email_verified,
username,
password_hash,
role::text AS role,
auth_source::text AS auth_source,
allowed_providers,
allowed_api_formats,
allowed_models,
rate_limit,
model_capability_settings,
is_active
FROM users
WHERE is_deleted IS FALSE
AND id = $1
LIMIT 1
"#;
const FIND_USER_AUTH_BY_ID_SQL: &str = r#"
SELECT
id,
email,
email_verified,
username,
password_hash,
role::text AS role,
auth_source::text AS auth_source,
allowed_providers,
allowed_api_formats,
allowed_models,
is_active,
is_deleted,
created_at,
last_login_at
FROM users
WHERE id = $1
LIMIT 1
"#;
const LIST_USER_AUTH_BY_IDS_SQL: &str = r#"
SELECT
id,
email,
email_verified,
username,
password_hash,
role::text AS role,
auth_source::text AS auth_source,
allowed_providers,
allowed_api_formats,
allowed_models,
is_active,
is_deleted,
created_at,
last_login_at
FROM users
WHERE id = ANY($1::text[])
ORDER BY id ASC
"#;
const FIND_USER_AUTH_BY_IDENTIFIER_SQL: &str = r#"
SELECT
id,
email,
email_verified,
username,
password_hash,
role::text AS role,
auth_source::text AS auth_source,
allowed_providers,
allowed_api_formats,
allowed_models,
is_active,
is_deleted,
created_at,
last_login_at
FROM users
WHERE email = $1 OR username = $1
LIMIT 1
"#;
#[derive(Debug, Clone)]
pub struct SqlxUserReadRepository {
pool: PgPool,
}
impl SqlxUserReadRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn list_users_by_ids(
&self,
user_ids: &[String],
) -> Result<Vec<StoredUserSummary>, DataLayerError> {
if user_ids.is_empty() {
return Ok(Vec::new());
}
collect_query_rows(
sqlx::query(LIST_USERS_BY_IDS_SQL)
.bind(user_ids)
.fetch(&self.pool),
map_user_row,
)
.await
}
pub async fn list_users_by_username_search(
&self,
username_search: &str,
) -> Result<Vec<StoredUserSummary>, DataLayerError> {
let username_search = username_search.trim();
if username_search.is_empty() {
return Ok(Vec::new());
}
collect_query_rows(
sqlx::query(LIST_USERS_BY_USERNAME_SEARCH_SQL)
.bind(format!("%{}%", username_search.to_ascii_lowercase()))
.fetch(&self.pool),
map_user_row,
)
.await
}
pub async fn list_non_admin_export_users(
&self,
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
collect_query_rows(
sqlx::query(LIST_NON_ADMIN_EXPORT_USERS_SQL).fetch(&self.pool),
map_user_export_row,
)
.await
}
pub async fn list_export_users(&self) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
collect_query_rows(
sqlx::query(LIST_EXPORT_USERS_SQL).fetch(&self.pool),
map_user_export_row,
)
.await
}
pub async fn list_export_users_page(
&self,
query: &UserExportListQuery,
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
let mut builder = QueryBuilder::<Postgres>::new(LIST_EXPORT_USERS_PAGE_PREFIX);
if let Some(role) = query.role.as_deref() {
builder
.push(" AND LOWER(role::text) = ")
.push_bind(role.trim().to_ascii_lowercase());
}
if let Some(is_active) = query.is_active {
builder.push(" AND is_active = ").push_bind(is_active);
}
builder
.push(" ORDER BY id ASC OFFSET ")
.push_bind(i64::try_from(query.skip).map_err(|_| {
DataLayerError::InvalidInput(format!("invalid user export skip: {}", query.skip))
})?)
.push(" LIMIT ")
.push_bind(i64::try_from(query.limit).map_err(|_| {
DataLayerError::InvalidInput(format!("invalid user export limit: {}", query.limit))
})?);
let query = builder.build();
collect_query_rows(query.fetch(&self.pool), map_user_export_row).await
}
pub async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> {
let row = sqlx::query(SUMMARIZE_EXPORT_USERS_SQL)
.fetch_one(&self.pool)
.await
.map_postgres_err()?;
Ok(UserExportSummary {
total: row.try_get::<i64, _>("total").map_postgres_err()?.max(0) as u64,
active: row.try_get::<i64, _>("active").map_postgres_err()?.max(0) as u64,
})
}
pub async fn find_export_user_by_id(
&self,
user_id: &str,
) -> Result<Option<StoredUserExportRow>, DataLayerError> {
let row = sqlx::query(FIND_EXPORT_USER_BY_ID_SQL)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref().map(map_user_export_row).transpose()
}
pub async fn list_user_auth_by_ids(
&self,
user_ids: &[String],
) -> Result<Vec<StoredUserAuthRecord>, DataLayerError> {
if user_ids.is_empty() {
return Ok(Vec::new());
}
collect_query_rows(
sqlx::query(LIST_USER_AUTH_BY_IDS_SQL)
.bind(user_ids)
.fetch(&self.pool),
map_user_auth_row,
)
.await
}
pub async fn find_user_auth_by_id(
&self,
user_id: &str,
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
let row = sqlx::query(FIND_USER_AUTH_BY_ID_SQL)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref().map(map_user_auth_row).transpose()
}
pub async fn find_user_auth_by_identifier(
&self,
identifier: &str,
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
let row = sqlx::query(FIND_USER_AUTH_BY_IDENTIFIER_SQL)
.bind(identifier)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref().map(map_user_auth_row).transpose()
}
}
fn map_user_row(row: &sqlx::postgres::PgRow) -> Result<StoredUserSummary, DataLayerError> {
StoredUserSummary::new(
row.try_get("id").map_postgres_err()?,
row.try_get("username").map_postgres_err()?,
row.try_get("email").map_postgres_err()?,
row.try_get("role").map_postgres_err()?,
row.try_get("is_active").map_postgres_err()?,
row.try_get("is_deleted").map_postgres_err()?,
)
}
fn map_user_export_row(row: &sqlx::postgres::PgRow) -> Result<StoredUserExportRow, DataLayerError> {
StoredUserExportRow::new(
row.try_get("id").map_postgres_err()?,
row.try_get("email").map_postgres_err()?,
row.try_get("email_verified").map_postgres_err()?,
row.try_get("username").map_postgres_err()?,
row.try_get("password_hash").map_postgres_err()?,
row.try_get("role").map_postgres_err()?,
row.try_get("auth_source").map_postgres_err()?,
row.try_get("allowed_providers").map_postgres_err()?,
row.try_get("allowed_api_formats").map_postgres_err()?,
row.try_get("allowed_models").map_postgres_err()?,
row.try_get("rate_limit").map_postgres_err()?,
row.try_get("model_capability_settings")
.map_postgres_err()?,
row.try_get("is_active").map_postgres_err()?,
)
}
fn map_user_auth_row(row: &sqlx::postgres::PgRow) -> Result<StoredUserAuthRecord, DataLayerError> {
StoredUserAuthRecord::new(
row.try_get("id").map_postgres_err()?,
row.try_get("email").map_postgres_err()?,
row.try_get("email_verified").map_postgres_err()?,
row.try_get("username").map_postgres_err()?,
row.try_get("password_hash").map_postgres_err()?,
row.try_get("role").map_postgres_err()?,
row.try_get("auth_source").map_postgres_err()?,
row.try_get("allowed_providers").map_postgres_err()?,
row.try_get("allowed_api_formats").map_postgres_err()?,
row.try_get("allowed_models").map_postgres_err()?,
row.try_get("is_active").map_postgres_err()?,
row.try_get("is_deleted").map_postgres_err()?,
row.try_get("created_at").map_postgres_err()?,
row.try_get("last_login_at").map_postgres_err()?,
)
}
async fn collect_query_rows<T, S>(
mut rows: S,
mapper: fn(&sqlx::postgres::PgRow) -> Result<T, DataLayerError>,
) -> Result<Vec<T>, DataLayerError>
where
S: futures_util::TryStream<Ok = sqlx::postgres::PgRow, Error = sqlx::Error> + Unpin,
{
let mut items = Vec::new();
while let Some(row) = rows.try_next().await.map_postgres_err()? {
items.push(mapper(&row)?);
}
Ok(items)
}
#[async_trait]
impl UserReadRepository for SqlxUserReadRepository {
async fn list_users_by_ids(
&self,
user_ids: &[String],
) -> Result<Vec<StoredUserSummary>, DataLayerError> {
self.list_users_by_ids(user_ids).await
}
async fn list_users_by_username_search(
&self,
username_search: &str,
) -> Result<Vec<StoredUserSummary>, DataLayerError> {
self.list_users_by_username_search(username_search).await
}
async fn list_non_admin_export_users(
&self,
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
self.list_non_admin_export_users().await
}
async fn list_export_users(&self) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
self.list_export_users().await
}
async fn list_export_users_page(
&self,
query: &UserExportListQuery,
) -> Result<Vec<StoredUserExportRow>, DataLayerError> {
self.list_export_users_page(query).await
}
async fn summarize_export_users(&self) -> Result<UserExportSummary, DataLayerError> {
self.summarize_export_users().await
}
async fn find_export_user_by_id(
&self,
user_id: &str,
) -> Result<Option<StoredUserExportRow>, DataLayerError> {
self.find_export_user_by_id(user_id).await
}
async fn find_user_auth_by_id(
&self,
user_id: &str,
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
self.find_user_auth_by_id(user_id).await
}
async fn list_user_auth_by_ids(
&self,
user_ids: &[String],
) -> Result<Vec<StoredUserAuthRecord>, DataLayerError> {
self.list_user_auth_by_ids(user_ids).await
}
async fn find_user_auth_by_identifier(
&self,
identifier: &str,
) -> Result<Option<StoredUserAuthRecord>, DataLayerError> {
self.find_user_auth_by_identifier(identifier).await
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -137,6 +137,56 @@ impl StoredUserAuthRecord {
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct LdapAuthUserProvisioningOutcome {
pub user: StoredUserAuthRecord,
pub created: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserOAuthLinkSummary {
pub provider_type: String,
pub display_name: String,
pub provider_username: Option<String>,
pub provider_email: Option<String>,
pub linked_at: Option<DateTime<Utc>>,
pub last_login_at: Option<DateTime<Utc>>,
pub provider_enabled: bool,
}
impl StoredUserOAuthLinkSummary {
#[allow(clippy::too_many_arguments)]
pub fn new(
provider_type: String,
display_name: String,
provider_username: Option<String>,
provider_email: Option<String>,
linked_at: Option<DateTime<Utc>>,
last_login_at: Option<DateTime<Utc>>,
provider_enabled: bool,
) -> Result<Self, crate::DataLayerError> {
if provider_type.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"user_oauth_links.provider_type is empty".to_string(),
));
}
if display_name.trim().is_empty() {
return Err(crate::DataLayerError::UnexpectedValue(
"oauth_providers.display_name is empty".to_string(),
));
}
Ok(Self {
provider_type,
display_name,
provider_username,
provider_email,
linked_at,
last_login_at,
provider_enabled,
})
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserExportRow {
pub id: String,
@@ -430,6 +480,232 @@ pub trait UserReadRepository: Send + Sync {
&self,
identifier: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn find_user_auth_by_email(
&self,
email: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn find_active_user_auth_by_email_ci(
&self,
email: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn find_user_auth_by_username(
&self,
username: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn list_user_oauth_links(
&self,
user_id: &str,
) -> Result<Vec<StoredUserOAuthLinkSummary>, crate::DataLayerError>;
async fn find_oauth_linked_user(
&self,
provider_type: &str,
provider_user_id: &str,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn touch_oauth_link(
&self,
provider_type: &str,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
extra_data: Option<Value>,
touched_at: DateTime<Utc>,
) -> Result<bool, crate::DataLayerError>;
async fn create_oauth_auth_user(
&self,
email: Option<String>,
username: String,
created_at: DateTime<Utc>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn find_oauth_link_owner(
&self,
provider_type: &str,
provider_user_id: &str,
) -> Result<Option<String>, crate::DataLayerError>;
async fn has_user_oauth_provider_link(
&self,
user_id: &str,
provider_type: &str,
) -> Result<bool, crate::DataLayerError>;
async fn count_user_oauth_links(&self, user_id: &str) -> Result<u64, crate::DataLayerError>;
#[allow(clippy::too_many_arguments)]
async fn upsert_user_oauth_link(
&self,
user_id: &str,
provider_type: &str,
provider_user_id: &str,
provider_username: Option<&str>,
provider_email: Option<&str>,
extra_data: Option<Value>,
linked_at: DateTime<Utc>,
) -> Result<(), crate::DataLayerError>;
async fn delete_user_oauth_link(
&self,
user_id: &str,
provider_type: &str,
) -> Result<bool, crate::DataLayerError>;
async fn get_or_create_ldap_auth_user(
&self,
email: String,
username: String,
ldap_dn: Option<String>,
ldap_username: Option<String>,
logged_in_at: DateTime<Utc>,
) -> Result<Option<LdapAuthUserProvisioningOutcome>, crate::DataLayerError>;
async fn touch_auth_user_last_login(
&self,
user_id: &str,
logged_in_at: DateTime<Utc>,
) -> Result<bool, crate::DataLayerError>;
async fn update_local_auth_user_profile(
&self,
user_id: &str,
email: Option<String>,
username: Option<String>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn update_local_auth_user_password_hash(
&self,
user_id: &str,
password_hash: String,
updated_at: DateTime<Utc>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
#[allow(clippy::too_many_arguments)]
async fn update_local_auth_user_admin_fields(
&self,
user_id: &str,
role: Option<String>,
allowed_providers_present: bool,
allowed_providers: Option<Vec<String>>,
allowed_api_formats_present: bool,
allowed_api_formats: Option<Vec<String>>,
allowed_models_present: bool,
allowed_models: Option<Vec<String>>,
rate_limit: Option<i32>,
is_active: Option<bool>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn update_user_model_capability_settings(
&self,
user_id: &str,
settings: Option<Value>,
) -> Result<Option<Value>, crate::DataLayerError>;
async fn create_local_auth_user(
&self,
email: Option<String>,
email_verified: bool,
username: String,
password_hash: String,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
#[allow(clippy::too_many_arguments)]
async fn create_local_auth_user_with_settings(
&self,
email: Option<String>,
email_verified: bool,
username: String,
password_hash: String,
role: String,
allowed_providers: Option<Vec<String>>,
allowed_api_formats: Option<Vec<String>>,
allowed_models: Option<Vec<String>>,
rate_limit: Option<i32>,
) -> Result<Option<StoredUserAuthRecord>, crate::DataLayerError>;
async fn delete_local_auth_user(&self, user_id: &str) -> Result<bool, crate::DataLayerError>;
async fn read_user_preferences(
&self,
user_id: &str,
) -> Result<Option<StoredUserPreferenceRecord>, crate::DataLayerError>;
async fn write_user_preferences(
&self,
preferences: &StoredUserPreferenceRecord,
) -> Result<Option<StoredUserPreferenceRecord>, crate::DataLayerError>;
async fn find_user_session(
&self,
user_id: &str,
session_id: &str,
) -> Result<Option<StoredUserSessionRecord>, crate::DataLayerError>;
async fn list_user_sessions(
&self,
user_id: &str,
) -> Result<Vec<StoredUserSessionRecord>, crate::DataLayerError>;
async fn create_user_session(
&self,
session: &StoredUserSessionRecord,
) -> Result<Option<StoredUserSessionRecord>, crate::DataLayerError>;
async fn touch_user_session(
&self,
user_id: &str,
session_id: &str,
touched_at: DateTime<Utc>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<bool, crate::DataLayerError>;
async fn update_user_session_device_label(
&self,
user_id: &str,
session_id: &str,
device_label: &str,
updated_at: DateTime<Utc>,
) -> Result<bool, crate::DataLayerError>;
#[allow(clippy::too_many_arguments)]
async fn rotate_user_session_refresh_token(
&self,
user_id: &str,
session_id: &str,
previous_refresh_token_hash: &str,
next_refresh_token_hash: &str,
rotated_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<bool, crate::DataLayerError>;
async fn revoke_user_session(
&self,
user_id: &str,
session_id: &str,
revoked_at: DateTime<Utc>,
reason: &str,
) -> Result<bool, crate::DataLayerError>;
async fn revoke_all_user_sessions(
&self,
user_id: &str,
revoked_at: DateTime<Utc>,
reason: &str,
) -> Result<u64, crate::DataLayerError>;
async fn count_active_admin_users(&self) -> Result<u64, crate::DataLayerError>;
async fn count_active_local_admin_users_with_valid_password(
&self,
) -> Result<u64, crate::DataLayerError>;
}
fn normalize_optional_json(value: Option<Value>) -> Option<Value> {