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