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}