Skip to main content

modelvault_core/
error.rs

1use std::fmt;
2
3/// Top-level error for [`crate::db::Database`] and storage: I/O, on-disk layout, or schema rules.
4///
5/// Convert from [`std::io::Error`] via `?` for convenience on file operations.
6/// Structured validation failure (0.6+): nested path and human-readable detail.
7#[derive(Debug, Clone)]
8pub struct ValidationError {
9    pub path: Vec<String>,
10    pub message: String,
11}
12
13#[derive(Debug)]
14pub enum DbError {
15    /// Failed to access the database file or path.
16    Io(std::io::Error),
17    /// Failed to parse or validate the on-disk format (header, superblock, segments, payloads).
18    Format(FormatError),
19    /// Catalog or row did not satisfy schema invariants.
20    Schema(SchemaError),
21    /// Row value failed type or constraint checks before persistence.
22    Validation(ValidationError),
23    /// Transaction nesting or API misuse (0.8+).
24    Transaction(TransactionError),
25    /// Query construction, parsing, or execution error (SQL adapter and query planner).
26    Query(QueryError),
27    /// Requested capability is not implemented in this release (e.g. nested field paths in rows).
28    NotImplemented,
29}
30
31/// Stable classification of core errors (suitable for matching in higher-level bindings).
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DbErrorKind {
34    Io,
35    Format,
36    Schema,
37    Validation,
38    Transaction,
39    Query,
40    NotImplemented,
41}
42
43impl DbError {
44    pub fn kind(&self) -> DbErrorKind {
45        match self {
46            DbError::Io(_) => DbErrorKind::Io,
47            DbError::Format(_) => DbErrorKind::Format,
48            DbError::Schema(_) => DbErrorKind::Schema,
49            DbError::Validation(_) => DbErrorKind::Validation,
50            DbError::Transaction(_) => DbErrorKind::Transaction,
51            DbError::Query(_) => DbErrorKind::Query,
52            DbError::NotImplemented => DbErrorKind::NotImplemented,
53        }
54    }
55}
56
57/// Query errors: unsupported query forms, bad syntax, or invalid paths.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct QueryError {
60    pub message: String,
61}
62
63/// Low-level decode/validation failures for bytes read from the store.
64#[derive(Debug)]
65pub enum FormatError {
66    /// File magic was not `TDB0`.
67    BadMagic { got: [u8; 4] },
68    /// Fewer bytes than expected for a fixed-size header region.
69    TruncatedHeader { got: usize, expected: usize },
70    /// Header or manifest reported an unsupported format or manifest version.
71    UnsupportedVersion { major: u16, minor: u16 },
72    /// Superblock slice shorter than [`crate::superblock::SUPERBLOCK_SIZE`].
73    TruncatedSuperblock { got: usize, expected: usize },
74    /// Superblock magic was not `TSB0`.
75    BadSuperblockMagic { got: [u8; 4] },
76    /// Superblock CRC did not match payload.
77    BadSuperblockChecksum,
78    /// Segment header slice shorter than expected.
79    TruncatedSegmentHeader { got: usize, expected: usize },
80    /// Segment header magic was not `TSG0`.
81    BadSegmentMagic { got: [u8; 4] },
82    /// Header CRC32C did not match header bytes.
83    BadSegmentHeaderChecksum,
84    /// Payload CRC32C did not match segment body.
85    BadSegmentPayloadChecksum,
86    /// Declared payload length would extend past the file end.
87    SegmentPayloadPastEof,
88    /// Invalid catalog segment payload (binary layout).
89    InvalidCatalogPayload { message: String },
90    /// Record segment payload truncated or malformed.
91    TruncatedRecordPayload,
92    /// Record payload type tag did not match schema.
93    RecordPayloadTypeMismatch,
94    /// UTF-8 in a record string field was invalid.
95    InvalidRecordUtf8,
96    /// Record payload used a composite type not supported in v1 row encoding.
97    RecordPayloadUnsupportedType,
98    /// Record payload version not supported.
99    UnknownRecordPayloadVersion { got: u16 },
100    /// Extra bytes after a decoded record payload.
101    TrailingRecordPayload,
102    /// Transaction marker segment payload was malformed.
103    InvalidTxnPayload { message: String },
104    /// Checkpoint payload references a replay offset before the checkpoint segment end.
105    InvalidCheckpointPayload { message: String },
106    /// On-disk log ends with an incomplete transaction or torn write; strict open refuses to modify.
107    UncleanLogTail {
108        /// First byte offset that may be discarded to reach a committed prefix (truncate target).
109        safe_end: u64,
110        reason: &'static str,
111    },
112}
113
114/// Transaction session errors (0.8+).
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum TransactionError {
117    /// `Database::transaction` was called while a transaction is already active.
118    NestedTransaction,
119}
120
121/// Schema and row-level validation errors (catalog replay, registration, insert/get).
122#[derive(Debug, Clone)]
123pub enum SchemaError {
124    /// Field path had no segments or an empty segment.
125    InvalidFieldPath,
126    /// Another collection already uses this name.
127    DuplicateCollectionName {
128        name: String,
129    },
130    /// No collection registered with this id.
131    UnknownCollection {
132        id: u32,
133    },
134    /// No collection registered under this name.
135    UnknownCollectionName {
136        name: String,
137    },
138    InvalidCollectionName,
139    InvalidSchemaVersion {
140        expected: u32,
141        got: u32,
142    },
143    /// `u32` schema version counter cannot be incremented further.
144    SchemaVersionExhausted,
145    UnexpectedCollectionId {
146        expected: u32,
147        got: u32,
148    },
149    /// Collection was created without a primary key (catalog v1); inserts are not supported.
150    NoPrimaryKey {
151        collection_id: u32,
152    },
153    /// Declared primary field is not a single top-level segment or not present in fields.
154    PrimaryFieldNotFound {
155        name: String,
156    },
157    /// New schema version drops or renames the primary-key field.
158    PrimaryFieldMissingInSchema {
159        name: String,
160    },
161    /// Insert row did not include the primary key field.
162    RowMissingPrimary {
163        name: String,
164    },
165    /// Insert row referenced an unknown field name.
166    RowUnknownField {
167        name: String,
168    },
169    /// Insert row omitted a non-primary field.
170    RowMissingField {
171        name: String,
172    },
173    /// Unique secondary index was violated (key already mapped to another primary key).
174    UniqueIndexViolation,
175    /// Proposed schema update is not compatible with the existing schema.
176    IncompatibleSchemaChange {
177        message: String,
178    },
179    /// Proposed schema update is supported, but requires an explicit migration step.
180    MigrationRequired {
181        message: String,
182    },
183    /// Secondary index references a primary key with no row in `latest`.
184    IndexRowMissing {
185        collection_id: u32,
186        index_name: String,
187    },
188}
189
190impl fmt::Display for ValidationError {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        if self.path.is_empty() {
193            return write!(f, "validation error: {}", self.message);
194        }
195        write!(
196            f,
197            "validation error at {}: {}",
198            self.path.join("."),
199            self.message
200        )
201    }
202}
203
204impl fmt::Display for DbError {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        match self {
207            DbError::Io(e) => write!(f, "i/o error: {e}"),
208            DbError::Format(e) => write!(f, "format error: {e}"),
209            DbError::Schema(e) => write!(f, "schema error: {e}"),
210            DbError::Validation(e) => write!(f, "{e}"),
211            DbError::Transaction(e) => write!(f, "transaction error: {e}"),
212            DbError::Query(e) => write!(f, "query error: {}", e.message),
213            DbError::NotImplemented => write!(f, "not implemented"),
214        }
215    }
216}
217
218impl fmt::Display for TransactionError {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        match self {
221            TransactionError::NestedTransaction => {
222                write!(f, "nested transactions are not supported")
223            }
224        }
225    }
226}
227
228impl fmt::Display for FormatError {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        match self {
231            FormatError::BadMagic { got } => {
232                write!(f, "bad magic bytes: expected \"TDB0\", got {:02x?}", got)
233            }
234            FormatError::TruncatedHeader { got, expected } => {
235                write!(f, "truncated header: got {got} bytes, expected {expected}")
236            }
237            FormatError::UnsupportedVersion { major, minor } => {
238                write!(f, "unsupported format version {major}.{minor}")
239            }
240            FormatError::TruncatedSuperblock { got, expected } => {
241                write!(
242                    f,
243                    "truncated superblock: got {got} bytes, expected {expected}"
244                )
245            }
246            FormatError::BadSuperblockMagic { got } => {
247                write!(
248                    f,
249                    "bad superblock magic bytes: expected \"TSB0\", got {:02x?}",
250                    got
251                )
252            }
253            FormatError::BadSuperblockChecksum => write!(f, "superblock checksum mismatch"),
254            FormatError::TruncatedSegmentHeader { got, expected } => {
255                write!(
256                    f,
257                    "truncated segment header: got {got} bytes, expected {expected}"
258                )
259            }
260            FormatError::BadSegmentMagic { got } => {
261                write!(
262                    f,
263                    "bad segment magic bytes: expected \"TSG0\", got {:02x?}",
264                    got
265                )
266            }
267            FormatError::BadSegmentHeaderChecksum => write!(f, "segment header checksum mismatch"),
268            FormatError::BadSegmentPayloadChecksum => {
269                write!(f, "segment payload checksum mismatch")
270            }
271            FormatError::SegmentPayloadPastEof => {
272                write!(f, "segment payload extends past end of file")
273            }
274            FormatError::InvalidCatalogPayload { message } => {
275                write!(f, "invalid catalog payload: {message}")
276            }
277            FormatError::TruncatedRecordPayload => write!(f, "truncated record payload"),
278            FormatError::RecordPayloadTypeMismatch => {
279                write!(f, "record payload type does not match schema")
280            }
281            FormatError::InvalidRecordUtf8 => write!(f, "invalid UTF-8 in record string"),
282            FormatError::RecordPayloadUnsupportedType => {
283                write!(f, "unsupported type in record payload v1")
284            }
285            FormatError::UnknownRecordPayloadVersion { got } => {
286                write!(f, "unknown record payload version {got}")
287            }
288            FormatError::TrailingRecordPayload => write!(f, "trailing bytes in record payload"),
289            FormatError::InvalidTxnPayload { message } => {
290                write!(f, "invalid transaction marker payload: {message}")
291            }
292            FormatError::InvalidCheckpointPayload { message } => {
293                write!(f, "invalid checkpoint payload: {message}")
294            }
295            FormatError::UncleanLogTail { safe_end, reason } => {
296                write!(
297                    f,
298                    "unclean log tail (strict open): {reason}; safe truncate end offset {safe_end}"
299                )
300            }
301        }
302    }
303}
304
305impl fmt::Display for SchemaError {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        match self {
308            SchemaError::InvalidFieldPath => write!(f, "invalid field path"),
309            SchemaError::DuplicateCollectionName { name } => {
310                write!(f, "duplicate collection name: {name:?}")
311            }
312            SchemaError::UnknownCollection { id } => {
313                write!(f, "unknown collection id {id}")
314            }
315            SchemaError::UnknownCollectionName { name } => {
316                write!(f, "unknown collection name {name:?}")
317            }
318            SchemaError::InvalidCollectionName => write!(f, "invalid collection name"),
319            SchemaError::InvalidSchemaVersion { expected, got } => {
320                write!(f, "invalid schema version: expected {expected}, got {got}")
321            }
322            SchemaError::SchemaVersionExhausted => {
323                write!(f, "schema version limit reached (cannot bump further)")
324            }
325            SchemaError::UnexpectedCollectionId { expected, got } => {
326                write!(
327                    f,
328                    "unexpected collection id in catalog replay: expected {expected}, got {got}"
329                )
330            }
331            SchemaError::NoPrimaryKey { collection_id } => {
332                write!(
333                    f,
334                    "collection {collection_id} has no primary key (upgrade catalog or re-register)"
335                )
336            }
337            SchemaError::PrimaryFieldNotFound { name } => {
338                write!(f, "primary field {name:?} not found as a top-level field")
339            }
340            SchemaError::PrimaryFieldMissingInSchema { name } => {
341                write!(
342                    f,
343                    "schema update must retain top-level primary field {name:?}"
344                )
345            }
346            SchemaError::RowMissingPrimary { name } => {
347                write!(f, "insert row missing primary key field {name:?}")
348            }
349            SchemaError::RowUnknownField { name } => {
350                write!(f, "insert row has unknown field {name:?}")
351            }
352            SchemaError::RowMissingField { name } => {
353                write!(f, "insert row missing field {name:?}")
354            }
355            SchemaError::UniqueIndexViolation => write!(f, "unique index violation"),
356            SchemaError::IncompatibleSchemaChange { message } => {
357                write!(f, "incompatible schema change: {message}")
358            }
359            SchemaError::MigrationRequired { message } => {
360                write!(f, "migration required: {message}")
361            }
362            SchemaError::IndexRowMissing {
363                collection_id,
364                index_name,
365            } => {
366                write!(
367                    f,
368                    "index {index_name:?} on collection {collection_id} references missing row"
369                )
370            }
371        }
372    }
373}
374
375impl std::error::Error for DbError {
376    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
377        match self {
378            DbError::Io(e) => Some(e),
379            DbError::Format(_) => None,
380            DbError::Schema(_) => None,
381            DbError::Validation(_) => None,
382            DbError::Transaction(_) => None,
383            DbError::Query(_) => None,
384            DbError::NotImplemented => None,
385        }
386    }
387}
388
389impl From<std::io::Error> for DbError {
390    fn from(value: std::io::Error) -> Self {
391        DbError::Io(value)
392    }
393}