Skip to main content

dpm_store/
error.rs

1use dpm_engine::EngineError;
2use dpm_model::ValidationError;
3use std::path::PathBuf;
4use thiserror::Error;
5
6/// A persistence operation that could not be completed safely.
7#[derive(Debug, Error)]
8pub enum StoreError {
9    /// SQLite failed while accessing the named database.
10    #[cfg(feature = "sqlite")]
11    #[error("{action} in {path}: {source}")]
12    Database {
13        /// Database location or the in-memory label.
14        path: PathBuf,
15        /// Failed persistence action.
16        action: &'static str,
17        /// Original database error.
18        #[source]
19        source: rusqlite::Error,
20    },
21    /// Snapshot or operation serialization failed.
22    #[error(transparent)]
23    Json(#[from] serde_json::Error),
24    /// The supplied graph violates model invariants.
25    #[error(transparent)]
26    Validation(#[from] ValidationError),
27    /// The supplied operation cannot produce a valid state transition.
28    #[error(transparent)]
29    Engine(#[from] EngineError),
30    /// A caller attempted to replace existing history during initialization.
31    #[error("workspace already exists at {0}")]
32    AlreadyInitialized(PathBuf),
33    /// Persistence requires an existing initial snapshot.
34    #[error("workspace is not initialized at {0}")]
35    NotInitialized(PathBuf),
36    /// The caller's base revision is stale.
37    #[error("revision conflict: store has {actual}, operation expected {expected}")]
38    RevisionConflict {
39        /// Revision required by the caller.
40        expected: u64,
41        /// Current stored revision.
42        actual: u64,
43    },
44    /// Snapshot revisions do not describe one consecutive wrapping operation.
45    #[error("invalid operation revision: base {base}, result {result}, plan {plan}")]
46    InvalidOperationRevision {
47        /// Revision before the operation.
48        base: u64,
49        /// Claimed resulting revision.
50        result: u64,
51        /// Supplied snapshot revision.
52        plan: u64,
53    },
54    /// Snapshot contents differ from the actual result of the supplied command.
55    #[error("snapshot does not match operation {0}")]
56    SnapshotMismatch(dpm_model::OperationId),
57    /// Stored revision metadata disagrees with the serialized plan.
58    #[error("corrupt {record} at {path}: row revision {row}, JSON revision {json}")]
59    CorruptSnapshot {
60        /// Database containing the inconsistent plan.
61        path: PathBuf,
62        /// Snapshot or genesis plan.
63        record: StoredRecord,
64        /// Revision in the SQLite row.
65        row: u64,
66        /// Revision inside the plan JSON.
67        json: u64,
68    },
69    /// The store was written by a binary with a different, unsupported layout version.
70    #[error(
71        "store at {path} has schema version {found}, but this dpm supports up to {supported}; \
72         nothing was written: open it with the dpm release that wrote it, or back it up with that \
73         release before upgrading"
74    )]
75    UnsupportedSchemaVersion {
76        /// Database carrying the version.
77        path: PathBuf,
78        /// Version recorded in the store header.
79        found: i64,
80        /// Newest version this binary understands.
81        supported: i64,
82    },
83    /// The store was written in a retired layout whose operation log cannot be replayed.
84    #[error(
85        "store at {path} has schema version {found}, which this dpm no longer opens (it writes \
86         version {current}); nothing was written. Back it up and keep it as the archive of its \
87         history, run `dpm export` with the dpm release that wrote it, and import that export \
88         into a new store with `dpm import <file>`"
89    )]
90    RetiredSchemaVersion {
91        /// Database carrying the version.
92        path: PathBuf,
93        /// Version recorded in the store header; 0 for DPM tables without a header.
94        found: i64,
95        /// Version this binary writes.
96        current: i64,
97    },
98    /// The file is SQLite but its tables are not a DPM store layout.
99    #[error("{path} is not a DPM store: {detail}")]
100    UnrecognizedSchema {
101        /// Inspected database.
102        path: PathBuf,
103        /// First layout difference found.
104        detail: String,
105    },
106    /// Backup and restore only ever create new files.
107    #[error("{path} already exists; choose a new path, existing files are never overwritten")]
108    TargetExists {
109        /// Existing file or SQLite side file at the target location.
110        path: PathBuf,
111    },
112    /// A filesystem operation outside SQLite failed.
113    #[error("{action} {path}: {source}")]
114    Io {
115        /// Failed filesystem action.
116        action: &'static str,
117        /// Affected location.
118        path: PathBuf,
119        /// Original I/O error.
120        #[source]
121        source: std::io::Error,
122    },
123    /// The online backup did not copy every page in one step.
124    #[error("backup into {path} did not complete: {state}")]
125    BackupIncomplete {
126        /// Destination being written.
127        path: PathBuf,
128        /// SQLite step result that ended the copy.
129        state: String,
130    },
131    /// SQLite's page-level integrity check reported damage.
132    #[error("integrity check failed for {path}: {}", problems.join("; "))]
133    IntegrityCheckFailed {
134        /// Damaged database.
135        path: PathBuf,
136        /// First reported problems.
137        problems: Vec<String>,
138    },
139    /// Operation revisions do not form one consecutive chain.
140    #[error(
141        "history of {path} breaks at sequence {sequence}: expected revision {expected}, found {found}"
142    )]
143    HistoryDiscontinuity {
144        /// Inspected database.
145        path: PathBuf,
146        /// Local append sequence of the offending operation.
147        sequence: u64,
148        /// Revision the chain requires.
149        expected: u64,
150        /// Revision the operation records.
151        found: u64,
152    },
153    /// The snapshot is not the state produced by the last recorded operation.
154    #[error(
155        "snapshot of {path} is at revision {snapshot}, but the last operation produced {last_operation}"
156    )]
157    SnapshotRevisionMismatch {
158        /// Inspected database.
159        path: PathBuf,
160        /// Revision of the stored snapshot.
161        snapshot: u64,
162        /// Resulting revision of the last operation.
163        last_operation: u64,
164    },
165    /// A stored JSON field no longer decodes.
166    #[error("corrupt {record} {field} in {path}: {source}")]
167    CorruptJson {
168        /// Database containing the record.
169        path: PathBuf,
170        /// Snapshot or operation holding the field.
171        record: StoredRecord,
172        /// Column holding the damaged JSON.
173        field: &'static str,
174        /// Decoding failure.
175        #[source]
176        source: serde_json::Error,
177    },
178    /// A stored column holds a value of the wrong type.
179    #[cfg(feature = "sqlite")]
180    #[error("corrupt {record} {field} in {path}: {source}")]
181    CorruptColumn {
182        /// Database containing the record.
183        path: PathBuf,
184        /// Snapshot or operation holding the field.
185        record: StoredRecord,
186        /// Damaged column.
187        field: &'static str,
188        /// Column decoding failure.
189        #[source]
190        source: rusqlite::Error,
191    },
192    /// Local append sequences skip a value, so an operation row was removed.
193    #[error("history of {path} skips from sequence {previous} to {sequence}")]
194    SequenceGap {
195        /// Inspected database.
196        path: PathBuf,
197        /// Sequence of the preceding operation.
198        previous: u64,
199        /// Next sequence found.
200        sequence: u64,
201    },
202    /// No operation leads from the genesis plan to the snapshot.
203    #[error(
204        "history of {path} is empty, but the snapshot moved from genesis revision {origin} to {snapshot}"
205    )]
206    MissingHistory {
207        /// Inspected database.
208        path: PathBuf,
209        /// Revision the history must start from.
210        origin: u64,
211        /// Revision of the stored snapshot.
212        snapshot: u64,
213    },
214    /// A store has a snapshot but no genesis plan to replay its history from.
215    #[error("{path} has a snapshot but no genesis plan")]
216    MissingGenesis {
217        /// Inspected database.
218        path: PathBuf,
219    },
220    /// The engine refuses a recorded operation when the history is replayed from genesis.
221    #[error("replaying the history of {path} fails at sequence {sequence}: {source}")]
222    ReplayRefused {
223        /// Inspected database.
224        path: PathBuf,
225        /// Local append sequence of the refused operation.
226        sequence: u64,
227        /// Engine refusal.
228        #[source]
229        source: EngineError,
230    },
231    /// Replaying genesis and every operation does not reproduce the stored snapshot.
232    #[error(
233        "replaying the history of {path} to revision {revision} does not reproduce the snapshot; \
234         differing: {}", differing.join(", ")
235    )]
236    ReplayDiverged {
237        /// Inspected database.
238        path: PathBuf,
239        /// Revision of the stored snapshot.
240        revision: u64,
241        /// Top-level plan fields whose replayed value differs from the snapshot.
242        differing: Vec<String>,
243    },
244    /// The store's lineage is missing, archived, or not the one the caller observed.
245    #[error(transparent)]
246    Lineage(#[from] LineageError),
247    /// An operation belongs to a different workspace than the store's genesis plan.
248    #[error("operation sequence {sequence} in {path} belongs to workspace {found}, not {expected}")]
249    ForeignOperation {
250        /// Inspected database.
251        path: PathBuf,
252        /// Local append sequence of the offending operation.
253        sequence: u64,
254        /// Workspace the operation records.
255        found: dpm_model::WorkspaceId,
256        /// Workspace of the genesis plan.
257        expected: dpm_model::WorkspaceId,
258    },
259    /// The operation identity is already recorded; the caller decides whether the resend matches.
260    #[error("operation {} is already recorded at sequence {}", .recorded.operation.operation.id, .recorded.sequence)]
261    DuplicateOperation {
262        /// The recorded operation with this identity.
263        recorded: Box<crate::HistoryEntry>,
264    },
265    /// Backup and restore targets must be a plain database file name in an existing directory.
266    #[error("cannot write a store to {path}: {reason}")]
267    InvalidTarget {
268        /// Requested destination.
269        path: PathBuf,
270        /// Why the destination is unusable.
271        reason: &'static str,
272    },
273}
274
275/// Why a store's lineage does not permit a read or write.
276#[derive(Debug, Error)]
277pub enum LineageError {
278    /// A store has no lineage row, so its writable history cannot be identified.
279    #[error("{path} records no lineage")]
280    Missing {
281        /// Inspected database.
282        path: PathBuf,
283    },
284    /// A restore forks a new writable lineage, so its source must be a sealed backup archive: an
285    /// interrupted copy of an archive stays sealed, while one of a live store would be a writable
286    /// file sharing that store's lineage.
287    #[error(
288        "{path} is a live store, not a backup archive; run `dpm backup` first and restore the backup"
289    )]
290    NotAnArchive {
291        /// Live store named as the restore source.
292        path: PathBuf,
293    },
294    /// A backup archive is read and verified, never written.
295    #[error(
296        "{path} is a backup archive; restore it into a new store with `dpm restore` to continue \
297         writing"
298    )]
299    Archived {
300        /// Archive the write targeted.
301        path: PathBuf,
302    },
303    /// The store continues a different writable history than the caller observed.
304    #[error(
305        "lineage conflict: store continues lineage {actual}, operation expected {expected}; \
306         reload before writing, histories are never merged"
307    )]
308    Mismatch {
309        /// Lineage the caller observed.
310        expected: dpm_model::LineageId,
311        /// Lineage of the store.
312        actual: dpm_model::LineageId,
313    },
314}
315
316/// Which stored record a corruption report refers to.
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub enum StoredRecord {
319    /// The authoritative plan snapshot.
320    Snapshot,
321    /// One operation, addressed by its local append sequence.
322    Operation {
323        /// Local append sequence.
324        sequence: u64,
325    },
326    /// The plan the operation history replays from.
327    Genesis,
328    /// The lineage row of the file.
329    Lineage,
330    /// A record of the run store.
331    Run,
332}
333
334impl std::fmt::Display for StoredRecord {
335    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        match self {
337            Self::Snapshot => formatter.write_str("snapshot"),
338            Self::Operation { sequence } => write!(formatter, "operation sequence {sequence}"),
339            Self::Genesis => formatter.write_str("genesis plan"),
340            Self::Lineage => formatter.write_str("lineage"),
341            Self::Run => formatter.write_str("run store record"),
342        }
343    }
344}
345
346impl StoreError {
347    /// Whether the stored data itself is damaged or inconsistent, as opposed to a refused request.
348    pub fn is_corruption(&self) -> bool {
349        match self {
350            Self::IntegrityCheckFailed { .. }
351            | Self::HistoryDiscontinuity { .. }
352            | Self::SnapshotRevisionMismatch { .. }
353            | Self::CorruptSnapshot { .. }
354            | Self::CorruptJson { .. }
355            | Self::SequenceGap { .. }
356            | Self::MissingHistory { .. }
357            | Self::MissingGenesis { .. }
358            | Self::Lineage(LineageError::Missing { .. })
359            | Self::ForeignOperation { .. }
360            | Self::ReplayRefused { .. }
361            | Self::ReplayDiverged { .. }
362            | Self::UnrecognizedSchema { .. } => true,
363            #[cfg(feature = "sqlite")]
364            Self::CorruptColumn { .. } => true,
365            #[cfg(feature = "sqlite")]
366            Self::Database { source, .. } => matches!(
367                source.sqlite_error_code(),
368                Some(rusqlite::ErrorCode::DatabaseCorrupt | rusqlite::ErrorCode::NotADatabase)
369            ),
370            _ => false,
371        }
372    }
373
374    /// Whether another connection held the database lock past the busy timeout; retrying later
375    /// may succeed, and nothing was written.
376    pub fn is_busy(&self) -> bool {
377        #[cfg(feature = "sqlite")]
378        if let Self::Database { source, .. } = self {
379            return matches!(
380                source.sqlite_error_code(),
381                Some(rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked)
382            );
383        }
384        false
385    }
386}
387
388#[cfg(feature = "sqlite")]
389pub(crate) fn database_error(
390    path: &std::path::Path,
391    action: &'static str,
392) -> impl FnOnce(rusqlite::Error) -> StoreError {
393    let path = path.to_path_buf();
394    move |source| StoreError::Database {
395        path,
396        action,
397        source,
398    }
399}