1#![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
116pub type Result<T> = std::result::Result<T, DbError>;
118
119#[derive(Clone, Copy, Debug, Eq, PartialEq)]
121pub enum DatabaseErrorKind {
122 Deadlock,
124 SerializationFailure,
126 LockTimeout,
129 UniqueConstraint,
131 ForeignKeyConstraint,
133}
134
135impl DatabaseErrorKind {
136 #[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
147pub 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 match sqlite_code.map(|code| code & 0xFF) {
215 Some(5 | 6) => Some(DatabaseErrorKind::LockTimeout),
216 _ => None,
217 }
218}
219
220#[derive(Debug, thiserror::Error)]
222pub enum DbError {
223 #[error("database connection error: {0}")]
225 DatabaseConnection(String),
226 #[error("database operation error: {0}")]
228 DatabaseOperation(String),
229 #[error("database operation error: {message}")]
231 DatabaseOperationClassified {
232 message: String,
233 kind: DatabaseErrorKind,
234 },
235 #[error("database commit outcome unknown: {message}")]
237 CommitOutcomeUnknown {
238 message: String,
239 kind: Option<DatabaseErrorKind>,
240 },
241 #[error("retry exhausted")]
243 RetryExhausted,
244 #[error("non-retryable error: {0}")]
246 NonRetryable(String),
247}
248
249impl DbError {
250 pub fn database_connection(error: impl std::fmt::Display) -> Self {
252 Self::DatabaseConnection(error.to_string())
253 }
254
255 pub fn database_operation(error: impl std::fmt::Display) -> Self {
257 Self::DatabaseOperation(error.to_string())
258 }
259
260 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 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 #[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 #[must_use]
294 pub fn commit_outcome_is_unknown(&self) -> bool {
295 matches!(self, Self::CommitOutcomeUnknown { .. })
296 }
297
298 pub fn non_retryable(error: impl std::fmt::Display) -> Self {
300 Self::NonRetryable(error.to_string())
301 }
302
303 #[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 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 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 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 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}