Skip to main content

systemprompt_traits/repository/
mod.rs

1//! The workspace's single repository error type.
2//!
3//! Every repository — infra or domain — returns [`RepositoryError`]. Its
4//! variants carry the classification an HTTP or job boundary needs (missing
5//! entity, conflict, constraint violation, corrupt stored data, backend
6//! failure) and keep the underlying error as a `#[source]` rather than its
7//! text. The caller-facing variants are structured: a missing entity or a
8//! conflict names the entity kind and its key, an invalid argument or a
9//! corrupt stored value names the field, and `reason` carries only what that
10//! structure cannot. With the `sqlx` feature, `From<sqlx::Error>` classifies a
11//! database error by SQLSTATE, so `?` on a query result yields `NotFound`,
12//! `Constraint` or `Database` rather than an opaque string.
13//!
14//! Copyright (c) systemprompt.io — Business Source License 1.1.
15//! See <https://systemprompt.io> for licensing details.
16
17#[cfg(feature = "sqlx")]
18mod sqlx_classify;
19
20use std::fmt;
21
22pub type BoxedSource = Box<dyn std::error::Error + Send + Sync + 'static>;
23
24/// The integrity constraint class a database rejected a write with.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ConstraintKind {
27    Unique,
28    Exclusion,
29    ForeignKey,
30    NotNull,
31    Check,
32}
33
34impl ConstraintKind {
35    #[must_use]
36    pub const fn code(self) -> &'static str {
37        match self {
38            Self::Unique => "unique_violation",
39            Self::Exclusion => "exclusion_violation",
40            Self::ForeignKey => "foreign_key_violation",
41            Self::NotNull => "not_null_violation",
42            Self::Check => "check_violation",
43        }
44    }
45
46    #[must_use]
47    pub const fn is_conflict(self) -> bool {
48        matches!(self, Self::Unique | Self::Exclusion | Self::ForeignKey)
49    }
50}
51
52impl fmt::Display for ConstraintKind {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str(self.code())
55    }
56}
57
58/// The single repository error type of the workspace.
59#[derive(Debug, thiserror::Error)]
60#[non_exhaustive]
61pub enum RepositoryError {
62    #[error("{entity} not found{}", key_suffix(.key.as_deref()))]
63    NotFound {
64        entity: &'static str,
65        key: Option<String>,
66    },
67
68    #[error("{entity} {key} conflicts: {reason}")]
69    Conflict {
70        entity: &'static str,
71        key: String,
72        reason: String,
73    },
74
75    #[error("{kind} on constraint {constraint}")]
76    Constraint {
77        kind: ConstraintKind,
78        constraint: String,
79        #[source]
80        source: BoxedSource,
81    },
82
83    #[error("invalid argument {field}: {reason}")]
84    InvalidArgument { field: &'static str, reason: String },
85
86    #[error("invalid stored data in {field}: {reason}")]
87    InvalidData { field: &'static str, reason: String },
88
89    #[error("could not decode {context}")]
90    Decode {
91        context: String,
92        #[source]
93        source: BoxedSource,
94    },
95
96    #[error("database error: {source}")]
97    Database {
98        sqlstate: Option<String>,
99        #[source]
100        source: BoxedSource,
101    },
102
103    #[error("serialization error: {0}")]
104    Serialization(#[from] serde_json::Error),
105
106    #[error("SQL could not be split into statements: {0}")]
107    SqlParse(#[source] BoxedSource),
108
109    #[error("Failed to establish database connection")]
110    Connection(#[source] Box<Self>),
111
112    #[error("Failed to execute SQL statement: {statement}")]
113    Statement {
114        statement: String,
115        #[source]
116        source: Box<Self>,
117    },
118
119    #[error("Failed to read SQL file {path}")]
120    SqlFile {
121        path: String,
122        #[source]
123        source: std::io::Error,
124    },
125
126    #[error("transaction already committed or rolled back")]
127    TransactionConsumed,
128}
129
130impl RepositoryError {
131    pub fn not_found(entity: &'static str, key: impl fmt::Display) -> Self {
132        Self::NotFound {
133            entity,
134            key: Some(key.to_string()),
135        }
136    }
137
138    pub fn conflict(
139        entity: &'static str,
140        key: impl fmt::Display,
141        reason: impl Into<String>,
142    ) -> Self {
143        Self::Conflict {
144            entity,
145            key: key.to_string(),
146            reason: reason.into(),
147        }
148    }
149
150    pub fn invalid_argument(field: &'static str, reason: impl Into<String>) -> Self {
151        Self::InvalidArgument {
152            field,
153            reason: reason.into(),
154        }
155    }
156
157    pub fn invalid_data(field: &'static str, reason: impl Into<String>) -> Self {
158        Self::InvalidData {
159            field,
160            reason: reason.into(),
161        }
162    }
163
164    pub fn decode(context: impl Into<String>, source: impl Into<BoxedSource>) -> Self {
165        Self::Decode {
166            context: context.into(),
167            source: source.into(),
168        }
169    }
170
171    pub fn database(source: impl Into<BoxedSource>) -> Self {
172        let source: BoxedSource = source.into();
173        #[cfg(feature = "sqlx")]
174        let source = match source.downcast::<sqlx::Error>() {
175            Ok(sqlx_error) => return Self::from(*sqlx_error),
176            Err(other) => other,
177        };
178        Self::Database {
179            sqlstate: None,
180            source,
181        }
182    }
183
184    #[must_use]
185    pub fn sqlstate(&self) -> Option<&str> {
186        match self {
187            Self::Database { sqlstate, .. } => sqlstate.as_deref(),
188            Self::Statement { source, .. } | Self::Connection(source) => source.sqlstate(),
189            _ => None,
190        }
191    }
192
193    #[must_use]
194    pub const fn is_not_found(&self) -> bool {
195        matches!(self, Self::NotFound { .. })
196    }
197
198    #[must_use]
199    pub const fn is_constraint(&self) -> bool {
200        matches!(self, Self::Constraint { .. })
201    }
202
203    #[must_use]
204    pub const fn is_conflict(&self) -> bool {
205        match self {
206            Self::Conflict { .. } => true,
207            Self::Constraint { kind, .. } => kind.is_conflict(),
208            _ => false,
209        }
210    }
211
212    #[must_use]
213    pub fn is_serialization_failure(&self) -> bool {
214        // Why: Postgres aborts one side of a serialization conflict (40001) or a
215        // deadlock (40P01) and documents both as "retry the transaction".
216        matches!(self.sqlstate(), Some("40001" | "40P01"))
217    }
218
219    #[must_use]
220    pub fn is_invalid_function_definition(&self) -> bool {
221        // Why: Postgres refuses CREATE OR REPLACE FUNCTION that changes the return
222        // type or parameter names of an existing function (42P13); only DROP then
223        // CREATE reshapes it.
224        self.sqlstate() == Some("42P13")
225    }
226}
227
228fn key_suffix(key: Option<&str>) -> String {
229    key.map(|key| format!(": {key}")).unwrap_or_default()
230}