Skip to main content

meerkat_sqlite/
error.rs

1//! Crate error type and storage-level error classification.
2
3use std::path::PathBuf;
4
5/// Errors produced by the shared SQLite mechanics.
6#[derive(Debug, thiserror::Error)]
7pub enum SqliteStoreError {
8    /// Filesystem-level failure (creating parent directories, fence lock
9    /// files, ...).
10    #[error("sqlite store io error: {0}")]
11    Io(#[from] std::io::Error),
12
13    /// Underlying SQLite failure.
14    #[error("sqlite error: {0}")]
15    Sqlite(#[from] rusqlite::Error),
16
17    /// The file's schema ledger records a version newer than this binary
18    /// supports. This is a refusal, not a corruption: a newer binary has
19    /// migrated the file and this binary must not touch it. Surfaces report
20    /// it as a typed, health-visible certification failure (a rollback
21    /// candidate fails cleanly) rather than crash-looping.
22    #[error(
23        "schema for domain `{domain}` is from the future: file has version {found}, \
24         this binary supports up to {supported}"
25    )]
26    SchemaFromTheFuture {
27        domain: String,
28        found: i64,
29        supported: i64,
30    },
31
32    /// The ledger records a real domain version, but that version is not one
33    /// of the exact released predecessors this binary is willing to upgrade.
34    /// This covers both versions below the compatibility floor and gaps
35    /// between an allowed predecessor and the current schema. Refusal happens
36    /// before schema or ledger mutation.
37    #[error(
38        "schema for domain `{domain}` is not a supported predecessor: file has version {found}, \
39         this binary supports version {supported} and accepts existing versions {allowed:?}"
40    )]
41    UnsupportedSchemaPredecessor {
42        domain: String,
43        found: i64,
44        supported: i64,
45        allowed: Vec<i64>,
46    },
47
48    /// A ledger row names an otherwise allowed current or released version,
49    /// but the domain-owned catalog does not exactly match that version's
50    /// frozen schema fingerprint. The row alone is not authority to
51    /// reinterpret a candidate or partially migrated schema.
52    #[error(
53        "schema fingerprint for domain `{domain}` version {version} does not match the \
54         allowed schema: {detail}"
55    )]
56    SchemaFingerprintMismatch {
57        domain: String,
58        version: i64,
59        detail: String,
60    },
61
62    /// A file has no ledger row for a domain but already contains one or more
63    /// objects owned by that domain. At the 0.8.10 compatibility floor this
64    /// is neither a fresh domain nor an authenticated released predecessor:
65    /// silently running idempotent DDL over it would bless an unknown or
66    /// unreleased candidate schema.
67    #[error(
68        "schema domain `{domain}` has no ledger row but already owns objects {objects:?}; \
69         refusing to infer or stamp an unversioned schema"
70    )]
71    UnledgeredDomainObjects {
72        domain: String,
73        objects: Vec<String>,
74    },
75
76    /// Explicit maintenance could not authenticate an unledgered owned
77    /// catalog as any exact migration prefix or frozen predecessor through
78    /// the requested target. No preparation, migration, or ledger mutation
79    /// has run.
80    #[error(
81        "unledgered schema domain `{domain}` does not match any authorized source catalog \
82         through version {target_version}; found owned objects {objects:?}"
83    )]
84    UnledgeredSchemaNoMatch {
85        domain: String,
86        target_version: i64,
87        objects: Vec<String>,
88    },
89
90    /// Explicit maintenance found more than one exact authorized source
91    /// version for an unledgered catalog. Inferring a version would be
92    /// ambiguous, so the file remains untouched.
93    #[error(
94        "unledgered schema domain `{domain}` ambiguously matches source versions {matches:?} \
95         through requested target version {target_version}"
96    )]
97    UnledgeredSchemaAmbiguous {
98        domain: String,
99        target_version: i64,
100        matches: Vec<i64>,
101    },
102
103    /// A registered migration failed while being applied. The surrounding
104    /// transaction is rolled back; the file is left at its prior version.
105    #[error("migration {version} (`{name}`) for domain `{domain}` failed: {source}")]
106    MigrationFailed {
107        domain: String,
108        version: i64,
109        name: String,
110        #[source]
111        source: rusqlite::Error,
112    },
113
114    /// A migration body ended the runner's IMMEDIATE transaction (COMMIT or
115    /// ROLLBACK, with or without re-BEGINning a fresh one), separating its
116    /// schema work from the ledger stamp. Custody is verified after every
117    /// body via a runner-owned savepoint; the domain is left unstamped.
118    #[error(
119        "migration {version} (`{name}`) for domain `{domain}` ended the runner's transaction; \
120         migration bodies must not COMMIT or ROLLBACK"
121    )]
122    MigrationBrokeTransaction {
123        domain: String,
124        version: i64,
125        name: String,
126    },
127
128    /// The `meerkat_schema` ledger table exists but is not the pinned shape
129    /// (`domain TEXT PRIMARY KEY, version INTEGER NOT NULL`), carries more
130    /// than one row for a domain, or records a non-positive version. This is
131    /// corrupt or foreign ledger state: it is refused, never healed by
132    /// re-running migrations over it.
133    #[error("meerkat_schema ledger is malformed: {detail}")]
134    LedgerMalformed { detail: String },
135
136    /// The Primary profile asked SQLite to establish `journal_mode=WAL` and
137    /// SQLite reported a different effective mode without raising an error
138    /// (the journal-mode pragma can silently keep the old mode). The
139    /// connection does not satisfy the profile's durability policy.
140    #[error("could not establish journal_mode=WAL on `{path}`: effective mode is `{actual}`")]
141    WalNotEstablished { path: PathBuf, actual: String },
142
143    /// A domain registered an invalid migration list (non-contiguous or
144    /// not starting at version 1). This is a programming error in the store
145    /// crate, caught before any file is touched.
146    #[error("domain `{domain}` registered an invalid migration list: {detail}")]
147    InvalidMigrationList { domain: String, detail: String },
148
149    /// The connection profile refused the requested open (for example a
150    /// non-creating profile pointed at a missing file).
151    #[error("cannot open `{path}` with profile {profile}: {detail}")]
152    OpenRefused {
153        path: PathBuf,
154        profile: &'static str,
155        detail: String,
156    },
157
158    /// The exclusive maintenance fence is held for this database: storage is
159    /// under offline maintenance and the operation must not proceed. (Also
160    /// returned by [`crate::fence::ExclusiveFence::acquire`] when in-flight
161    /// operations did not drain within the deadline.)
162    #[error("maintenance fence is held for `{path}`; storage is under offline maintenance")]
163    MaintenanceFenceHeld { path: PathBuf },
164}
165
166/// Storage-level classification of a SQLite error.
167///
168/// This is deliberately narrower than the store-boundary taxonomy
169/// (transient / stale / corrupt): staleness (CAS conflicts, revision guards)
170/// is a store-contract concept invisible at this layer, so store crates map
171/// their own guard failures to their stale variants and use this
172/// classification for everything that reaches raw SQLite.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum SqliteErrorClass {
175    /// Lock contention or interruption; safe to retry only for idempotent or
176    /// CAS-keyed operations (see the crate-level retryability note).
177    Transient,
178    /// The file is not (or no longer) a usable database.
179    Corrupt,
180    /// Everything else: constraint violations, misuse, API errors. The store
181    /// layer decides what these mean.
182    Other,
183}
184
185/// Classify a rusqlite error at the storage level.
186///
187/// Adoption contract: store crates route every raw [`rusqlite::Error`]
188/// through this one classifier when deciding transient-vs-corrupt at their
189/// store boundary, instead of re-matching SQLite error codes locally.
190/// [`SqliteErrorClass::Other`] is the store layer's to interpret (constraint
191/// violations become CAS/stale semantics there, not here). Classification
192/// alone never authorizes a retry — see the crate-level retryability note.
193pub fn classify_sqlite_error(error: &rusqlite::Error) -> SqliteErrorClass {
194    use rusqlite::ErrorCode;
195    match error {
196        rusqlite::Error::SqliteFailure(f, _) => match f.code {
197            ErrorCode::DatabaseBusy
198            | ErrorCode::DatabaseLocked
199            | ErrorCode::OperationInterrupted => SqliteErrorClass::Transient,
200            ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase => SqliteErrorClass::Corrupt,
201            _ => SqliteErrorClass::Other,
202        },
203        _ => SqliteErrorClass::Other,
204    }
205}
206
207/// True when the error is SQLITE_BUSY or SQLITE_LOCKED — the nonblocking
208/// admission probes (write fences) map exactly these to a typed backoff.
209pub fn is_busy_or_locked(error: &rusqlite::Error) -> bool {
210    use rusqlite::ErrorCode;
211    matches!(
212        error,
213        rusqlite::Error::SqliteFailure(f, _)
214            if matches!(f.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
215    )
216}
217
218#[cfg(test)]
219#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
220mod tests {
221    use super::*;
222
223    fn sqlite_failure(code: rusqlite::ErrorCode) -> rusqlite::Error {
224        rusqlite::Error::SqliteFailure(
225            rusqlite::ffi::Error {
226                code,
227                extended_code: 0,
228            },
229            None,
230        )
231    }
232
233    #[test]
234    fn busy_and_locked_classify_transient() {
235        for code in [
236            rusqlite::ErrorCode::DatabaseBusy,
237            rusqlite::ErrorCode::DatabaseLocked,
238        ] {
239            let err = sqlite_failure(code);
240            assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Transient);
241            assert!(is_busy_or_locked(&err));
242        }
243    }
244
245    #[test]
246    fn corruption_classifies_corrupt() {
247        for code in [
248            rusqlite::ErrorCode::DatabaseCorrupt,
249            rusqlite::ErrorCode::NotADatabase,
250        ] {
251            let err = sqlite_failure(code);
252            assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Corrupt);
253            assert!(!is_busy_or_locked(&err));
254        }
255    }
256
257    #[test]
258    fn constraint_violation_classifies_other() {
259        let err = sqlite_failure(rusqlite::ErrorCode::ConstraintViolation);
260        assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Other);
261        assert!(!is_busy_or_locked(&err));
262    }
263}