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    /// `commit_transaction` was called with no active transaction.
120    NoActiveTransaction,
121}
122
123/// Schema and row-level validation errors (catalog replay, registration, insert/get).
124#[derive(Debug, Clone)]
125pub enum SchemaError {
126    /// Field path had no segments or an empty segment.
127    InvalidFieldPath,
128    /// Another collection already uses this name.
129    DuplicateCollectionName {
130        name: String,
131    },
132    /// No collection registered with this id.
133    UnknownCollection {
134        id: u32,
135    },
136    /// No collection registered under this name.
137    UnknownCollectionName {
138        name: String,
139    },
140    InvalidCollectionName,
141    InvalidSchemaVersion {
142        expected: u32,
143        got: u32,
144    },
145    /// `u32` schema version counter cannot be incremented further.
146    SchemaVersionExhausted,
147    UnexpectedCollectionId {
148        expected: u32,
149        got: u32,
150    },
151    /// Collection was created without a primary key (catalog v1); inserts are not supported.
152    NoPrimaryKey {
153        collection_id: u32,
154    },
155    /// Declared primary field is not a single top-level segment or not present in fields.
156    PrimaryFieldNotFound {
157        name: String,
158    },
159    /// New schema version drops or renames the primary-key field.
160    PrimaryFieldMissingInSchema {
161        name: String,
162    },
163    /// Insert row did not include the primary key field.
164    RowMissingPrimary {
165        name: String,
166    },
167    /// Insert row referenced an unknown field name.
168    RowUnknownField {
169        name: String,
170    },
171    /// Insert row omitted a non-primary field.
172    RowMissingField {
173        name: String,
174    },
175    /// Unique secondary index was violated (key already mapped to another primary key).
176    UniqueIndexViolation,
177    /// Proposed schema update is not compatible with the existing schema.
178    IncompatibleSchemaChange {
179        message: String,
180    },
181    /// Proposed schema update is supported, but requires an explicit migration step.
182    MigrationRequired {
183        message: String,
184    },
185    /// Secondary index references a primary key with no row in `latest`.
186    IndexRowMissing {
187        collection_id: u32,
188        index_name: String,
189    },
190    /// Primary key scalar type does not match the collection primary field type.
191    PrimaryKeyTypeMismatch {
192        collection_id: u32,
193    },
194}
195
196impl fmt::Display for ValidationError {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        if self.path.is_empty() {
199            return write!(f, "validation error: {}", self.message);
200        }
201        write!(
202            f,
203            "validation error at {}: {}",
204            self.path.join("."),
205            self.message
206        )
207    }
208}
209
210impl fmt::Display for DbError {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        match self {
213            DbError::Io(e) => write!(f, "i/o error: {e}"),
214            DbError::Format(e) => write!(f, "format error: {e}"),
215            DbError::Schema(e) => write!(f, "schema error: {e}"),
216            DbError::Validation(e) => write!(f, "{e}"),
217            DbError::Transaction(e) => write!(f, "transaction error: {e}"),
218            DbError::Query(e) => write!(f, "query error: {}", e.message),
219            DbError::NotImplemented => write!(f, "not implemented"),
220        }
221    }
222}
223
224impl fmt::Display for TransactionError {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        match self {
227            TransactionError::NestedTransaction => {
228                write!(f, "nested transactions are not supported")
229            }
230            TransactionError::NoActiveTransaction => {
231                write!(f, "no active transaction")
232            }
233        }
234    }
235}
236
237impl fmt::Display for FormatError {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        match self {
240            FormatError::BadMagic { got } => {
241                write!(f, "bad magic bytes: expected \"TDB0\", got {:02x?}", got)
242            }
243            FormatError::TruncatedHeader { got, expected } => {
244                write!(f, "truncated header: got {got} bytes, expected {expected}")
245            }
246            FormatError::UnsupportedVersion { major, minor } => {
247                write!(f, "unsupported format version {major}.{minor}")
248            }
249            FormatError::TruncatedSuperblock { got, expected } => {
250                write!(
251                    f,
252                    "truncated superblock: got {got} bytes, expected {expected}"
253                )
254            }
255            FormatError::BadSuperblockMagic { got } => {
256                write!(
257                    f,
258                    "bad superblock magic bytes: expected \"TSB0\", got {:02x?}",
259                    got
260                )
261            }
262            FormatError::BadSuperblockChecksum => write!(f, "superblock checksum mismatch"),
263            FormatError::TruncatedSegmentHeader { got, expected } => {
264                write!(
265                    f,
266                    "truncated segment header: got {got} bytes, expected {expected}"
267                )
268            }
269            FormatError::BadSegmentMagic { got } => {
270                write!(
271                    f,
272                    "bad segment magic bytes: expected \"TSG0\", got {:02x?}",
273                    got
274                )
275            }
276            FormatError::BadSegmentHeaderChecksum => write!(f, "segment header checksum mismatch"),
277            FormatError::BadSegmentPayloadChecksum => {
278                write!(f, "segment payload checksum mismatch")
279            }
280            FormatError::SegmentPayloadPastEof => {
281                write!(f, "segment payload extends past end of file")
282            }
283            FormatError::InvalidCatalogPayload { message } => {
284                write!(f, "invalid catalog payload: {message}")
285            }
286            FormatError::TruncatedRecordPayload => write!(f, "truncated record payload"),
287            FormatError::RecordPayloadTypeMismatch => {
288                write!(f, "record payload type does not match schema")
289            }
290            FormatError::InvalidRecordUtf8 => write!(f, "invalid UTF-8 in record string"),
291            FormatError::RecordPayloadUnsupportedType => {
292                write!(f, "unsupported type in record payload v1")
293            }
294            FormatError::UnknownRecordPayloadVersion { got } => {
295                write!(f, "unknown record payload version {got}")
296            }
297            FormatError::TrailingRecordPayload => write!(f, "trailing bytes in record payload"),
298            FormatError::InvalidTxnPayload { message } => {
299                write!(f, "invalid transaction marker payload: {message}")
300            }
301            FormatError::InvalidCheckpointPayload { message } => {
302                write!(f, "invalid checkpoint payload: {message}")
303            }
304            FormatError::UncleanLogTail { safe_end, reason } => {
305                write!(
306                    f,
307                    "unclean log tail (strict open): {reason}; safe truncate end offset {safe_end}"
308                )
309            }
310        }
311    }
312}
313
314impl fmt::Display for SchemaError {
315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316        match self {
317            SchemaError::InvalidFieldPath => write!(f, "invalid field path"),
318            SchemaError::DuplicateCollectionName { name } => {
319                write!(f, "duplicate collection name: {name:?}")
320            }
321            SchemaError::UnknownCollection { id } => {
322                write!(f, "unknown collection id {id}")
323            }
324            SchemaError::UnknownCollectionName { name } => {
325                write!(f, "unknown collection name {name:?}")
326            }
327            SchemaError::InvalidCollectionName => write!(f, "invalid collection name"),
328            SchemaError::InvalidSchemaVersion { expected, got } => {
329                write!(f, "invalid schema version: expected {expected}, got {got}")
330            }
331            SchemaError::SchemaVersionExhausted => {
332                write!(f, "schema version limit reached (cannot bump further)")
333            }
334            SchemaError::UnexpectedCollectionId { expected, got } => {
335                write!(
336                    f,
337                    "unexpected collection id in catalog replay: expected {expected}, got {got}"
338                )
339            }
340            SchemaError::NoPrimaryKey { collection_id } => {
341                write!(
342                    f,
343                    "collection {collection_id} has no primary key (upgrade catalog or re-register)"
344                )
345            }
346            SchemaError::PrimaryFieldNotFound { name } => {
347                write!(f, "primary field {name:?} not found as a top-level field")
348            }
349            SchemaError::PrimaryFieldMissingInSchema { name } => {
350                write!(
351                    f,
352                    "schema update must retain top-level primary field {name:?}"
353                )
354            }
355            SchemaError::RowMissingPrimary { name } => {
356                write!(f, "insert row missing primary key field {name:?}")
357            }
358            SchemaError::RowUnknownField { name } => {
359                write!(f, "insert row has unknown field {name:?}")
360            }
361            SchemaError::RowMissingField { name } => {
362                write!(f, "insert row missing field {name:?}")
363            }
364            SchemaError::UniqueIndexViolation => write!(f, "unique index violation"),
365            SchemaError::IncompatibleSchemaChange { message } => {
366                write!(f, "incompatible schema change: {message}")
367            }
368            SchemaError::MigrationRequired { message } => {
369                write!(f, "migration required: {message}")
370            }
371            SchemaError::IndexRowMissing {
372                collection_id,
373                index_name,
374            } => {
375                write!(
376                    f,
377                    "index {index_name:?} on collection {collection_id} references missing row"
378                )
379            }
380            SchemaError::PrimaryKeyTypeMismatch { collection_id } => {
381                write!(
382                    f,
383                    "primary key type mismatch for collection {collection_id}"
384                )
385            }
386        }
387    }
388}
389
390impl std::error::Error for DbError {
391    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
392        match self {
393            DbError::Io(e) => Some(e),
394            DbError::Format(_) => None,
395            DbError::Schema(_) => None,
396            DbError::Validation(_) => None,
397            DbError::Transaction(_) => None,
398            DbError::Query(_) => None,
399            DbError::NotImplemented => None,
400        }
401    }
402}
403
404impl From<std::io::Error> for DbError {
405    fn from(value: std::io::Error) -> Self {
406        DbError::Io(value)
407    }
408}