Skip to main content

aster_forge_db/
lib.rs

1//! Shared database utilities for Aster services.
2//!
3//! This crate contains framework-neutral SeaORM helpers: connection setup, retry policy, offset
4//! pagination, full-text search query helpers, whitelisted sorting, and transaction wrappers.
5//! Product migrations, entities, and repository-specific query logic intentionally remain outside
6//! this crate.
7#![deny(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8#![cfg_attr(
9    not(test),
10    deny(
11        clippy::unwrap_used,
12        clippy::unreachable,
13        clippy::expect_used,
14        clippy::panic,
15        clippy::unimplemented,
16        clippy::todo
17    )
18)]
19
20#[cfg(feature = "audit-log")]
21pub mod audit_log;
22#[cfg(feature = "runtime-component")]
23mod component;
24pub mod connection;
25mod index;
26#[cfg(feature = "mail-outbox")]
27pub mod mail_outbox;
28pub mod pagination;
29pub mod retry;
30#[cfg(feature = "runtime-lease")]
31pub mod runtime_lease;
32#[cfg(feature = "scheduled-task")]
33pub mod scheduled_task;
34pub mod search_query;
35pub mod sort;
36#[cfg(feature = "system-config")]
37pub mod system_config;
38pub mod transaction;
39
40#[cfg(feature = "audit-log")]
41pub use audit_log::{
42    AUDIT_LOG_ACTION_COLUMN, AUDIT_LOG_ACTION_CREATED_ID_INDEX,
43    AUDIT_LOG_ACTION_CREATED_USER_INDEX, AUDIT_LOG_ACTION_INDEX, AUDIT_LOG_CREATED_AT_COLUMN,
44    AUDIT_LOG_CREATED_AT_INDEX, AUDIT_LOG_CREATED_ID_INDEX, AUDIT_LOG_DETAILS_COLUMN,
45    AUDIT_LOG_ENTITY_ID_COLUMN, AUDIT_LOG_ENTITY_NAME_COLUMN, AUDIT_LOG_ENTITY_TYPE_COLUMN,
46    AUDIT_LOG_ENTITY_TYPE_CREATED_ID_INDEX, AUDIT_LOG_ID_COLUMN, AUDIT_LOG_IP_ADDRESS_COLUMN,
47    AUDIT_LOG_USER_AGENT_COLUMN, AUDIT_LOG_USER_CREATED_ID_INDEX, AUDIT_LOG_USER_ID_COLUMN,
48    AUDIT_LOG_USER_ID_INDEX, AUDIT_LOGS_TABLE, AuditLogCreate, AuditLogCursorSlice,
49    AuditLogDbStore, AuditLogQuery, count_audit_logs_created_between,
50    count_audit_logs_created_between_with_actions,
51    count_distinct_audit_log_users_created_between_with_actions, create_audit_log_requests,
52    create_audit_log_row, create_audit_log_rows, create_audit_logs_action_created_id_index,
53    create_audit_logs_action_created_user_index, create_audit_logs_action_index,
54    create_audit_logs_base_indexes, create_audit_logs_created_at_index,
55    create_audit_logs_created_id_index, create_audit_logs_entity_type_created_id_index,
56    create_audit_logs_query_indexes, create_audit_logs_table,
57    create_audit_logs_user_created_id_index, create_audit_logs_user_id_index,
58    delete_audit_logs_before, drop_audit_logs_table, find_audit_logs_with_filters_cursor,
59};
60#[cfg(feature = "runtime-component")]
61pub use component::{
62    DATABASE_COMPONENT, DATABASE_CONNECTIONS_SHUTDOWN_PHASE, DATABASE_HEALTH_CHECK,
63    DATABASE_HEALTH_CHECK_TIMEOUT, DatabaseHealthComponent, DatabaseRuntimeComponent,
64    check_database_component, database_component, database_component_after,
65    database_health_component, database_health_options, ping_database,
66};
67pub use connection::{
68    DatabaseConfig, DbHandles, connect, connect_reader_for_writer,
69    connect_reader_for_writer_with_metrics, connect_with_metrics,
70};
71pub use index::{drop_index_if_exists, rename_mysql_index_if_exists};
72#[cfg(feature = "mail-outbox")]
73pub use mail_outbox::{
74    MAIL_OUTBOX_ATTEMPT_COUNT_COLUMN, MAIL_OUTBOX_CREATED_AT_COLUMN, MAIL_OUTBOX_DUE_INDEX,
75    MAIL_OUTBOX_ID_COLUMN, MAIL_OUTBOX_LAST_ERROR_COLUMN, MAIL_OUTBOX_NEXT_ATTEMPT_AT_COLUMN,
76    MAIL_OUTBOX_PAYLOAD_JSON_COLUMN, MAIL_OUTBOX_PROCESSING_INDEX,
77    MAIL_OUTBOX_PROCESSING_STARTED_AT_COLUMN, MAIL_OUTBOX_SENT_AT_COLUMN,
78    MAIL_OUTBOX_SENT_AT_INDEX, MAIL_OUTBOX_STATUS_COLUMN, MAIL_OUTBOX_TABLE,
79    MAIL_OUTBOX_TEMPLATE_CODE_COLUMN, MAIL_OUTBOX_TO_ADDRESS_COLUMN, MAIL_OUTBOX_TO_NAME_COLUMN,
80    MAIL_OUTBOX_UPDATED_AT_COLUMN, MailOutboxCreate, MailOutboxDbStore,
81    create_mail_outbox_due_index, create_mail_outbox_processing_index, create_mail_outbox_row,
82    create_mail_outbox_sent_at_index, create_mail_outbox_table, drop_mail_outbox_table,
83};
84#[cfg(feature = "runtime-lease")]
85pub use runtime_lease::{
86    RUNTIME_LEASE_CREATED_AT_COLUMN, RUNTIME_LEASE_EXPIRES_AT_COLUMN, RUNTIME_LEASE_ID_COLUMN,
87    RUNTIME_LEASE_LAST_RENEWED_AT_COLUMN, RUNTIME_LEASE_OWNER_ID_COLUMN,
88    RUNTIME_LEASE_UPDATED_AT_COLUMN, RUNTIME_LEASES_TABLE, RuntimeLeaseDbStore,
89    create_runtime_leases_table, drop_runtime_leases_table,
90};
91#[cfg(feature = "scheduled-task")]
92pub use scheduled_task::{
93    SCHEDULED_TASK_CLAIM_EXPIRES_AT_COLUMN, SCHEDULED_TASK_CLAIM_OWNER_ID_COLUMN,
94    SCHEDULED_TASK_CREATED_AT_COLUMN, SCHEDULED_TASK_DISPLAY_NAME_COLUMN, SCHEDULED_TASK_ID_COLUMN,
95    SCHEDULED_TASK_LAST_CLAIMED_AT_COLUMN, SCHEDULED_TASK_LAST_FINISHED_AT_COLUMN,
96    SCHEDULED_TASK_NAME_COLUMN, SCHEDULED_TASK_NAMESPACE_COLUMN,
97    SCHEDULED_TASK_NAMESPACE_NAME_UNIQUE_INDEX, SCHEDULED_TASK_NEXT_RUN_AT_COLUMN,
98    SCHEDULED_TASK_NEXT_RUN_INDEX, SCHEDULED_TASK_UPDATED_AT_COLUMN, SCHEDULED_TASKS_TABLE,
99    ScheduledTaskDbStore, create_scheduled_tasks_namespace_name_unique_index,
100    create_scheduled_tasks_next_run_index, create_scheduled_tasks_table,
101    drop_scheduled_tasks_table,
102};
103#[cfg(feature = "system-config")]
104pub use system_config::{
105    PresentedSystemConfig, SystemConfigCursorSlice, SystemConfigDbBinding, SystemConfigDbStore,
106    SystemConfigUpsert, present_system_config,
107};
108#[cfg(feature = "system-config")]
109pub use system_config::{
110    SYSTEM_CONFIG_CATEGORY_COLUMN, SYSTEM_CONFIG_DESCRIPTION_COLUMN, SYSTEM_CONFIG_ID_COLUMN,
111    SYSTEM_CONFIG_IS_SENSITIVE_COLUMN, SYSTEM_CONFIG_KEY_COLUMN, SYSTEM_CONFIG_KEY_UNIQUE_INDEX,
112    SYSTEM_CONFIG_NAMESPACE_COLUMN, SYSTEM_CONFIG_REQUIRES_RESTART_COLUMN,
113    SYSTEM_CONFIG_SOURCE_COLUMN, SYSTEM_CONFIG_TABLE, SYSTEM_CONFIG_UPDATED_AT_COLUMN,
114    SYSTEM_CONFIG_UPDATED_BY_COLUMN, SYSTEM_CONFIG_VALUE_COLUMN, SYSTEM_CONFIG_VALUE_TYPE_COLUMN,
115    SYSTEM_CONFIG_VISIBILITY_COLUMN, create_system_config_key_unique_index,
116    create_system_config_table, drop_system_config_table,
117};
118
119/// Result type returned by database helpers.
120pub type Result<T> = std::result::Result<T, DbError>;
121
122/// Database failure classes that are stable enough for infrastructure retry decisions.
123#[derive(Clone, Copy, Debug, Eq, PartialEq)]
124pub enum DatabaseErrorKind {
125    /// The database aborted the current transaction because of a deadlock.
126    Deadlock,
127    /// The database aborted the transaction because its serialization snapshot could not commit.
128    SerializationFailure,
129    /// The database rejected an operation after a lock wait timeout, or reported the
130    /// database as busy/locked (SQLite).
131    LockTimeout,
132    /// A unique or primary-key constraint rejected the operation.
133    UniqueConstraint,
134    /// A foreign-key constraint rejected the operation.
135    ForeignKeyConstraint,
136}
137
138impl DatabaseErrorKind {
139    /// Returns whether the failure class is a transient locking conflict that a bounded
140    /// retry at the correct boundary can resolve.
141    pub fn is_transient_locking(self) -> bool {
142        matches!(
143            self,
144            Self::Deadlock | Self::SerializationFailure | Self::LockTimeout
145        )
146    }
147}
148
149/// Classifies driver-native database errors without relying on localized messages.
150pub fn database_error_kind(error: &sea_orm::DbErr) -> Option<DatabaseErrorKind> {
151    use sea_orm::{DbErr, RuntimeErr};
152
153    let sqlx_error = match error {
154        DbErr::Exec(RuntimeErr::SqlxError(error)) | DbErr::Query(RuntimeErr::SqlxError(error)) => {
155            error.as_ref()
156        }
157        _ => return None,
158    };
159    let sea_orm::sqlx::Error::Database(database_error) = sqlx_error else {
160        return None;
161    };
162
163    let mysql_number = database_error
164        .try_downcast_ref::<sea_orm::sqlx::mysql::MySqlDatabaseError>()
165        .map(sea_orm::sqlx::mysql::MySqlDatabaseError::number);
166    let postgres_code = database_error
167        .try_downcast_ref::<sea_orm::sqlx::postgres::PgDatabaseError>()
168        .map(sea_orm::sqlx::postgres::PgDatabaseError::code);
169    let sqlite_code = database_error
170        .try_downcast_ref::<sea_orm::sqlx::sqlite::SqliteError>()
171        .and_then(|error| {
172            use sea_orm::sqlx::error::DatabaseError;
173
174            error.code()
175        })
176        .and_then(|code| code.parse::<i32>().ok());
177    database_error_kind_from_signals(
178        database_error.kind(),
179        mysql_number,
180        postgres_code,
181        sqlite_code,
182    )
183}
184
185fn database_error_kind_from_signals(
186    driver_kind: sea_orm::sqlx::error::ErrorKind,
187    mysql_number: Option<u16>,
188    postgres_code: Option<&str>,
189    sqlite_code: Option<i32>,
190) -> Option<DatabaseErrorKind> {
191    use sea_orm::sqlx::error::ErrorKind;
192
193    match driver_kind {
194        ErrorKind::UniqueViolation => return Some(DatabaseErrorKind::UniqueConstraint),
195        ErrorKind::ForeignKeyViolation => return Some(DatabaseErrorKind::ForeignKeyConstraint),
196        _ => {}
197    }
198    if let Some(number) = mysql_number {
199        match number {
200            1205 => return Some(DatabaseErrorKind::LockTimeout),
201            1213 => return Some(DatabaseErrorKind::Deadlock),
202            _ => {}
203        }
204    }
205    if let Some(code) = postgres_code {
206        match code {
207            "40P01" => return Some(DatabaseErrorKind::Deadlock),
208            "40001" => return Some(DatabaseErrorKind::SerializationFailure),
209            "55P03" => return Some(DatabaseErrorKind::LockTimeout),
210            _ => {}
211        }
212    }
213    // SQLite reports lock contention through the extended result code; the primary code
214    // lives in the low byte (e.g. SQLITE_BUSY_SNAPSHOT = 517 belongs to the SQLITE_BUSY = 5
215    // family), so match on the masked value to cover the extended variants.
216    match sqlite_code.map(|code| code & 0xFF) {
217        Some(5 | 6) => Some(DatabaseErrorKind::LockTimeout),
218        _ => None,
219    }
220}
221
222/// Errors returned by database helpers.
223#[derive(Debug, thiserror::Error)]
224pub enum DbError {
225    /// A database connection could not be established.
226    #[error("database connection error: {0}")]
227    DatabaseConnection(String),
228    /// A database query, transaction, or setup operation failed.
229    #[error("database operation error: {0}")]
230    DatabaseOperation(String),
231    /// A database operation error with a driver-native classification.
232    #[error("database operation error: {message}")]
233    DatabaseOperationClassified {
234        message: String,
235        kind: DatabaseErrorKind,
236    },
237    /// The commit response was lost after the transaction may have been committed.
238    #[error("database commit outcome unknown: {message}")]
239    CommitOutcomeUnknown {
240        message: String,
241        kind: Option<DatabaseErrorKind>,
242    },
243    /// Retry loop exhausted without a final operation error.
244    #[error("retry exhausted")]
245    RetryExhausted,
246    /// Operation failed with an error that should not be retried.
247    #[error("non-retryable error: {0}")]
248    NonRetryable(String),
249}
250
251impl DbError {
252    /// Creates a database-connection error from a displayable error.
253    pub fn database_connection(error: impl std::fmt::Display) -> Self {
254        Self::DatabaseConnection(error.to_string())
255    }
256
257    /// Creates a database-operation error from a displayable error.
258    pub fn database_operation(error: impl std::fmt::Display) -> Self {
259        Self::DatabaseOperation(error.to_string())
260    }
261
262    /// Creates a database-operation error while preserving its driver-native classification.
263    pub fn database_operation_classified(
264        error: impl std::fmt::Display,
265        kind: DatabaseErrorKind,
266    ) -> Self {
267        Self::DatabaseOperationClassified {
268            message: error.to_string(),
269            kind,
270        }
271    }
272
273    /// Creates an error for a commit whose final server-side outcome is unknown.
274    pub fn commit_outcome_unknown(
275        error: impl std::fmt::Display,
276        kind: Option<DatabaseErrorKind>,
277    ) -> Self {
278        Self::CommitOutcomeUnknown {
279            message: error.to_string(),
280            kind,
281        }
282    }
283
284    /// Returns the driver-native classification, when one was available.
285    pub fn database_error_kind(&self) -> Option<DatabaseErrorKind> {
286        match self {
287            Self::DatabaseOperationClassified { kind, .. } => Some(*kind),
288            Self::CommitOutcomeUnknown { kind, .. } => *kind,
289            _ => None,
290        }
291    }
292
293    /// Returns whether this error came from a commit with an unknown final outcome.
294    pub fn commit_outcome_is_unknown(&self) -> bool {
295        matches!(self, Self::CommitOutcomeUnknown { .. })
296    }
297
298    /// Creates a non-retryable error from a displayable error.
299    pub fn non_retryable(error: impl std::fmt::Display) -> Self {
300        Self::NonRetryable(error.to_string())
301    }
302
303    /// Returns whether the error is considered retryable by `retry::with_retry`.
304    ///
305    /// Only connection failures and driver-classified transient locking conflicts
306    /// (deadlock, serialization failure, lock timeout) qualify. Unclassified operation
307    /// errors are not retried: without a driver classification there is no evidence the
308    /// operation failed in a retry-safe way, so callers see the failure immediately.
309    pub fn is_retryable(&self) -> bool {
310        match self {
311            Self::DatabaseConnection(_) => true,
312            Self::DatabaseOperationClassified { kind, .. } => kind.is_transient_locking(),
313            _ => false,
314        }
315    }
316}
317
318impl From<sea_orm::DbErr> for DbError {
319    fn from(value: sea_orm::DbErr) -> Self {
320        match database_error_kind(&value) {
321            Some(kind) => Self::database_operation_classified(value, kind),
322            None => Self::database_operation(value),
323        }
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::{DatabaseErrorKind, DbError, database_error_kind_from_signals};
330    use sea_orm::sqlx::error::ErrorKind;
331
332    #[test]
333    fn database_error_kind_covers_common_driver_signals() {
334        assert_eq!(
335            database_error_kind_from_signals(ErrorKind::Other, Some(1213), None, None),
336            Some(DatabaseErrorKind::Deadlock)
337        );
338        assert_eq!(
339            database_error_kind_from_signals(ErrorKind::Other, Some(1205), None, None),
340            Some(DatabaseErrorKind::LockTimeout)
341        );
342        assert_eq!(
343            database_error_kind_from_signals(ErrorKind::Other, None, Some("40P01"), None),
344            Some(DatabaseErrorKind::Deadlock)
345        );
346        assert_eq!(
347            database_error_kind_from_signals(ErrorKind::Other, None, Some("40001"), None),
348            Some(DatabaseErrorKind::SerializationFailure)
349        );
350        assert_eq!(
351            database_error_kind_from_signals(ErrorKind::Other, None, Some("55P03"), None),
352            Some(DatabaseErrorKind::LockTimeout)
353        );
354    }
355
356    #[test]
357    fn database_error_kind_covers_sqlite_busy_and_locked_families() {
358        // SQLITE_BUSY = 5 and SQLITE_LOCKED = 6, including extended variants whose
359        // high byte carries extra context (e.g. SQLITE_BUSY_SNAPSHOT = 517).
360        for code in [5, 6, 261, 517, 262] {
361            assert_eq!(
362                database_error_kind_from_signals(ErrorKind::Other, None, None, Some(code)),
363                Some(DatabaseErrorKind::LockTimeout),
364                "sqlite code {code} should classify as a lock timeout"
365            );
366        }
367        // SQLITE_ERROR = 1 and SQLITE_CONSTRAINT = 19 carry no retryable locking meaning.
368        for code in [1, 19, 0] {
369            assert_eq!(
370                database_error_kind_from_signals(ErrorKind::Other, None, None, Some(code)),
371                None,
372                "sqlite code {code} should stay unclassified"
373            );
374        }
375    }
376
377    #[test]
378    fn database_error_kind_prefers_cross_backend_constraint_kind() {
379        assert_eq!(
380            database_error_kind_from_signals(ErrorKind::UniqueViolation, Some(1213), None, None),
381            Some(DatabaseErrorKind::UniqueConstraint)
382        );
383        assert_eq!(
384            database_error_kind_from_signals(ErrorKind::ForeignKeyViolation, None, None, None),
385            Some(DatabaseErrorKind::ForeignKeyConstraint)
386        );
387        // A SQLite locking code must not override a cross-backend constraint kind.
388        assert_eq!(
389            database_error_kind_from_signals(ErrorKind::UniqueViolation, None, None, Some(5)),
390            Some(DatabaseErrorKind::UniqueConstraint)
391        );
392    }
393
394    #[test]
395    fn database_error_kind_ignores_unknown_or_non_driver_signals() {
396        assert_eq!(
397            database_error_kind_from_signals(ErrorKind::Other, Some(9999), Some("99999"), None),
398            None
399        );
400        assert_eq!(
401            super::database_error_kind(&sea_orm::DbErr::Custom("not a driver error".to_string())),
402            None
403        );
404    }
405
406    #[test]
407    fn db_error_constructors_preserve_messages() {
408        assert_eq!(
409            DbError::database_connection("offline").to_string(),
410            "database connection error: offline"
411        );
412        assert_eq!(
413            DbError::database_operation("bad query").to_string(),
414            "database operation error: bad query"
415        );
416        assert_eq!(
417            DbError::non_retryable("invalid config").to_string(),
418            "non-retryable error: invalid config"
419        );
420        assert_eq!(DbError::RetryExhausted.to_string(), "retry exhausted");
421    }
422
423    #[test]
424    fn retryable_classification_matches_error_kind() {
425        assert!(DbError::database_connection("offline").is_retryable());
426        for kind in [
427            DatabaseErrorKind::Deadlock,
428            DatabaseErrorKind::SerializationFailure,
429            DatabaseErrorKind::LockTimeout,
430        ] {
431            assert!(
432                DbError::database_operation_classified("conflict", kind).is_retryable(),
433                "{kind:?} should be retryable"
434            );
435        }
436        // Unclassified operation errors carry no evidence of retry safety.
437        assert!(!DbError::database_operation("locked").is_retryable());
438        for kind in [
439            DatabaseErrorKind::UniqueConstraint,
440            DatabaseErrorKind::ForeignKeyConstraint,
441        ] {
442            assert!(
443                !DbError::database_operation_classified("constraint", kind).is_retryable(),
444                "{kind:?} should not be retryable"
445            );
446        }
447        assert!(!DbError::RetryExhausted.is_retryable());
448        assert!(!DbError::non_retryable("invalid config").is_retryable());
449    }
450
451    #[test]
452    fn commit_outcome_unknown_preserves_kind_and_marker() {
453        let error = DbError::commit_outcome_unknown(
454            "connection lost after COMMIT",
455            Some(DatabaseErrorKind::Deadlock),
456        );
457        assert!(error.commit_outcome_is_unknown());
458        assert_eq!(
459            error.database_error_kind(),
460            Some(DatabaseErrorKind::Deadlock)
461        );
462        assert!(!error.is_retryable());
463    }
464
465    #[test]
466    fn sea_orm_errors_are_mapped_to_operation_errors() {
467        let error = DbError::from(sea_orm::DbErr::Custom("custom failure".to_string()));
468
469        assert!(matches!(error, DbError::DatabaseOperation(_)));
470        assert!(error.to_string().contains("custom failure"));
471    }
472}