Skip to main content

siderite_migrations/
error.rs

1//! Errors raised by the migration loader, autodetector, executor and CLI.
2
3use siderite_orm::{BackendCapabilityError, BackendKind, OrmError};
4use thiserror::Error;
5
6/// Failure of a migration command, graph check, or DDL step.
7#[derive(Debug, Error)]
8#[non_exhaustive]
9pub enum MigrationError {
10    /// An explicit SQL script may have committed unrecorded effects.
11    #[error(
12        "migration `{id}` has uncertain RunSQL at operation {operation}; \
13        reconcile its effects before clearing the intent and retrying"
14    )]
15    UncertainSqlStep {
16        /// Migration id.
17        id: String,
18        /// Zero-based operation index.
19        operation: usize,
20    },
21    /// A non-transactional callback may have committed unrecorded effects.
22    #[error(
23        "migration `{id}` has uncertain RunRust `{callback}` at operation \
24        {operation}; reconcile its effects before clearing the intent, or \
25        explicitly register it as replay safe"
26    )]
27    UncertainRustStep {
28        /// Migration id.
29        id: String,
30        /// Callback name.
31        callback: String,
32        /// Zero-based operation index in the recorded direction.
33        operation: usize,
34    },
35    /// The advisory migration lock could not be acquired before its deadline.
36    ///
37    /// Use [`Migrator::lock_holder`](crate::Migrator::lock_holder) on this
38    /// path to name the session that holds it.
39    #[error("timed out waiting for the migration lock")]
40    LockTimeout,
41    /// The run finished, but the release of the migration lock could not be
42    /// confirmed (for example the lock session was lost), so lock ownership
43    /// is uncertain. The listed migrations were applied (`migrate`) or
44    /// unapplied (`rollback`); inspect before retrying.
45    #[error(
46        "the run finished for {applied:?}, but the migration lock release \
47        could not be confirmed, so lock ownership is uncertain: {source}"
48    )]
49    LockReleaseUncertain {
50        /// Ids written to (`migrate`) or removed from (`rollback`) the
51        /// history table by this run.
52        applied: Vec<String>,
53        /// Why the release could not be confirmed.
54        #[source]
55        source: Box<MigrationError>,
56    },
57    /// A migration lists a dependency that is not on disk.
58    #[error("unknown dependency `{dependency}` of migration `{id}`")]
59    MissingDependency {
60        /// Migration that named the missing dependency.
61        id: String,
62        /// Missing migration id.
63        dependency: String,
64    },
65    /// The dependency graph contains a cycle.
66    #[error("cycle in migration graph: {}", .nodes.join(" -> "))]
67    Cycle {
68        /// Nodes of one cycle, starting and ending at the same id.
69        nodes: Vec<String>,
70    },
71    /// More than one leaf (unmerged heads).
72    #[error(
73        "multiple migration heads: {}. Create a merge migration or run squashmigrations",
74        .heads.join(", ")
75    )]
76    MultipleHeads {
77        /// Leaf migration ids, sorted.
78        heads: Vec<String>,
79    },
80    /// An applied migration's file no longer matches the checksum recorded in
81    /// the history table.
82    #[error("checksum mismatch for applied migration `{id}` (history {history}, file {file})")]
83    ChecksumMismatch {
84        /// Migration id.
85        id: String,
86        /// Checksum stored in `siderite_migrations`.
87        history: String,
88        /// Checksum of the file currently on disk.
89        file: String,
90    },
91    /// A rollback was requested for a migration that cannot be reversed.
92    #[error("cannot reverse migration `{id}`: {reason}")]
93    Irreversible {
94        /// Migration id.
95        id: String,
96        /// Why reversal is impossible.
97        reason: String,
98    },
99    /// Filesystem failure.
100    #[error(transparent)]
101    Io(#[from] std::io::Error),
102    /// Migration JSON could not be encoded or decoded.
103    #[error("invalid migration JSON: {0}")]
104    Json(#[from] serde_json::Error),
105    /// The ORM backend rejected a statement or connection.
106    #[error(transparent)]
107    Orm(#[from] OrmError),
108    /// A requested SQL feature is not supported by this backend.
109    #[error(transparent)]
110    Capability(#[from] BackendCapabilityError),
111    /// Schema migrations are only implemented for PostgreSQL, SQLite and MySQL.
112    #[error("schema migrations are not supported by {0:?}")]
113    UnsupportedBackend(BackendKind),
114    /// The operation does not apply to the current [`crate::state::ProjectState`].
115    #[error("{0}")]
116    State(String),
117    /// A named `RunRust` operation is not in the [`crate::registry::MigrationRegistry`].
118    #[error("unregistered RunRust operation `{0}`")]
119    UnregisteredRust(String),
120    /// Command-line usage error.
121    #[error("{0}")]
122    Usage(String),
123    /// MySQL DDL committed earlier statements, then a later one failed.
124    #[error(
125        "MySQL migration `{id}` failed on statement {index} of {total} after earlier DDL committed (repair the schema by hand, then re-run): {source}"
126    )]
127    MysqlPartial {
128        /// Migration id.
129        id: String,
130        /// 1-based statement that failed.
131        index: usize,
132        /// Number of SQL statements in the migration.
133        total: usize,
134        /// Driver error from the failing statement.
135        #[source]
136        source: OrmError,
137    },
138    /// A MySQL data operation failed after earlier statements committed.
139    #[error(
140        "MySQL migration `{id}` failed in operation {index} of {total} ({summary}) after earlier statements committed (repair the schema by hand, then re-run): {source}"
141    )]
142    MysqlOpPartial {
143        /// Migration id.
144        id: String,
145        /// 1-based operation that failed.
146        index: usize,
147        /// Number of operations in the migration.
148        total: usize,
149        /// Short label of the failing operation.
150        summary: String,
151        /// The operation's own error.
152        #[source]
153        source: Box<MigrationError>,
154    },
155    /// A non-transactional PostgreSQL migration (`atomic: false`) failed on a
156    /// statement after earlier statements committed.
157    #[error(
158        "PostgreSQL migration `{id}` (atomic: false) failed on statement {index} of {total} after earlier statements committed; they ran outside a transaction and cannot be rolled back (repair the schema by hand, then re-run `migrate` to resume at the failed statement): {source}"
159    )]
160    PostgresPartial {
161        /// Migration id.
162        id: String,
163        /// 1-based statement that failed.
164        index: usize,
165        /// Number of SQL statements in the migration.
166        total: usize,
167        /// Driver error from the failing statement.
168        #[source]
169        source: OrmError,
170    },
171    /// A `RunRust` step of a non-transactional PostgreSQL migration
172    /// (`atomic: false`) failed after earlier statements committed.
173    #[error(
174        "PostgreSQL migration `{id}` (atomic: false) failed in operation {index} of {total} ({summary}) after earlier statements committed; they ran outside a transaction and cannot be rolled back (repair the schema by hand, then re-run `migrate` to resume at the failed operation): {source}"
175    )]
176    PostgresOpPartial {
177        /// Migration id.
178        id: String,
179        /// 1-based operation that failed.
180        index: usize,
181        /// Number of operations in the migration.
182        total: usize,
183        /// Short label of the failing operation.
184        summary: String,
185        /// The operation's own error.
186        #[source]
187        source: Box<MigrationError>,
188    },
189}
190
191impl MigrationError {
192    /// [`State`](Self::State) helper.
193    pub fn state(msg: impl Into<String>) -> Self {
194        Self::State(msg.into())
195    }
196
197    /// [`Usage`](Self::Usage) helper.
198    pub fn usage(msg: impl Into<String>) -> Self {
199        Self::Usage(msg.into())
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use siderite_orm::BackendError;
207
208    #[test]
209    fn mysql_partial_names_the_failed_statement() {
210        let err = MigrationError::MysqlPartial {
211            id: "0002_add".into(),
212            index: 2,
213            total: 3,
214            source: BackendError::Database("table exists".into()).into(),
215        };
216        let msg = err.to_string();
217        assert!(msg.contains("statement 2 of 3"), "{msg}");
218        assert!(msg.contains("0002_add"), "{msg}");
219        assert!(msg.contains("repair the schema by hand"), "{msg}");
220    }
221
222    #[test]
223    fn mysql_op_partial_names_the_failed_operation() {
224        let err = MigrationError::MysqlOpPartial {
225            id: "0003_data".into(),
226            index: 2,
227            total: 2,
228            summary: "RunRust seed".into(),
229            source: Box::new(MigrationError::UnregisteredRust("seed".into())),
230        };
231        let msg = err.to_string();
232        assert!(msg.contains("operation 2 of 2 (RunRust seed)"), "{msg}");
233        assert!(msg.contains("0003_data"), "{msg}");
234        assert!(msg.contains("repair the schema by hand"), "{msg}");
235    }
236
237    #[test]
238    fn postgres_partial_names_the_failed_statement() {
239        let err = MigrationError::PostgresPartial {
240            id: "0004_add".into(),
241            index: 2,
242            total: 3,
243            source: BackendError::Database("relation already exists".into()).into(),
244        };
245        let msg = err.to_string();
246        assert!(msg.contains("statement 2 of 3"), "{msg}");
247        assert!(msg.contains("0004_add"), "{msg}");
248        assert!(msg.contains("atomic: false"), "{msg}");
249        assert!(msg.contains("cannot be rolled back"), "{msg}");
250        assert!(msg.contains("repair the schema by hand"), "{msg}");
251    }
252
253    #[test]
254    fn postgres_op_partial_names_the_failed_operation() {
255        let err = MigrationError::PostgresOpPartial {
256            id: "0005_data".into(),
257            index: 2,
258            total: 2,
259            summary: "RunRust seed".into(),
260            source: Box::new(MigrationError::UnregisteredRust("seed".into())),
261        };
262        let msg = err.to_string();
263        assert!(msg.contains("operation 2 of 2 (RunRust seed)"), "{msg}");
264        assert!(msg.contains("0005_data"), "{msg}");
265        assert!(msg.contains("atomic: false"), "{msg}");
266        assert!(msg.contains("repair the schema by hand"), "{msg}");
267    }
268}