Skip to main content

macrame/
error.rs

1use thiserror::Error;
2
3/// The two intervals of a [`DbError::OverlappingInterval`], boxed out of the
4/// error enum (D-075).
5///
6/// Both are reported because neither alone identifies the conflict: the caller
7/// knows what they asserted and not what it collided with, and a message naming
8/// only the stored interval reads as though the assertion were the innocent one.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Overlap {
11    pub source_id: String,
12    pub target_id: String,
13    pub edge_type: String,
14    /// The interval the caller asserted.
15    pub valid_from: String,
16    pub valid_to: String,
17    /// The interval already stored that it collides with.
18    pub existing_from: String,
19    pub existing_to: String,
20}
21
22/// Central error type for the Macrame bitemporal ledger database.
23#[derive(Debug, Error)]
24pub enum DbError {
25    #[error("engine: {0}")]
26    Engine(#[from] libsql::Error),
27
28    #[error("migration to v{to} failed: {reason}")]
29    Migration { to: u32, reason: String },
30
31    #[error("invalid edge type {0} (must match [A-Z0-9]+)")]
32    InvalidEdgeType(String),
33
34    // NOTE: the spec (§7) names these fields `source` / `target`. `source` is a
35    // reserved field name for thiserror (it is inferred as the error source and
36    // requires `std::error::Error`), so the schema column names are used instead.
37    #[error(
38        "{source_id} -> {target_id} ({edge_type}) already has an open interval; retire it first"
39    )]
40    SingleOpenViolation {
41        source_id: String,
42        target_id: String,
43        edge_type: String,
44    },
45
46    #[error("node {0} not found")]
47    NotFound(String),
48
49    #[error("embedding dim {got}, expected {expected} for model {model}")]
50    DimMismatch {
51        got: usize,
52        expected: usize,
53        model: String,
54    },
55
56    /// A model name is spliced into DDL and queries as a table identifier, and
57    /// identifiers cannot be bound as parameters. Validating the name is what
58    /// makes that splice safe, so an invalid one is refused rather than escaped.
59    #[error("invalid embedding model name {0:?}: expected [a-z][a-z0-9_]* up to 48 characters")]
60    InvalidModelName(String),
61
62    #[error("embedding model {model} is not registered (no {table} table)")]
63    ModelNotRegistered { model: String, table: String },
64
65    #[error("subgraph exceeds budget ({n} > {budget})")]
66    SubgraphTooLarge { n: usize, budget: usize },
67
68    /// Dijkstra and A* settle a node permanently the first time they pop it,
69    /// which is only sound when no later edge can reduce the distance — that is,
70    /// when weights are non-negative. `links.weight` is a bare `REAL NOT NULL`
71    /// with no CHECK, so the guarantee has to be established at load time. The
72    /// alternative is a shortest-path result that is quietly just a path.
73    #[error("edge {source_id} -> {target_id} has weight {weight}, which shortest-path analytics cannot use")]
74    NegativeEdgeWeight {
75        source_id: String,
76        target_id: String,
77        weight: f64,
78    },
79
80    #[error("replay corrupt at seq {seq}: {reason}")]
81    ReplayCorrupt { seq: i64, reason: String },
82
83    /// A snapshot this build cannot read. Distinct from [`Self::ReplayCorrupt`]
84    /// on purpose: corruption is a fault to report, an incompatible snapshot is
85    /// the ordinary consequence of an upgrade, and the correct response is to
86    /// discard the file and fold from the log instead (D-043).
87    #[error("snapshot {path} is not readable by this build: {reason}")]
88    SnapshotIncompatible { path: String, reason: String },
89
90    #[error("payload v{got} unsupported (max {max})")]
91    PayloadVersion { got: u8, max: u8 },
92
93    #[error("physical delete blocked outside archive session ({table})")]
94    ArchiveViolation { table: String },
95
96    /// A traversal asked about the past without saying which text it wanted
97    /// (T3.2, D-085).
98    ///
99    /// `TraversalBuilder::as_of(ts)` fixes the *topology* at `ts`. Node
100    /// attributes are a second, independent question, and the default answer —
101    /// `AttributeMode::Current` — is live text. That combination returns the
102    /// past's graph wearing the present's titles, which is a legitimate thing to
103    /// want and a terrible thing to get by accident.
104    ///
105    /// It used to be a `tracing::warn!`, which is invisible in any application
106    /// that has not configured a subscriber. This is the same statement as a
107    /// value the caller cannot miss.
108    ///
109    /// Fix by stating the mode: `.attribute_mode(AttributeMode::AtTime)` for the
110    /// past's text, or `.attribute_mode(AttributeMode::Current)` to affirm that
111    /// live text is what was meant.
112    #[error(
113        "traversal as_of({as_of}) did not state an attribute mode: topology at \
114         {as_of} would be returned with attributes as they are *now*. Call \
115         .attribute_mode(AttributeMode::AtTime) for attributes as believed at \
116         {as_of}, or .attribute_mode(AttributeMode::Current) to confirm live \
117         attributes are intended"
118    )]
119    AttributeModeUnstated { as_of: String },
120    /// [`crate::Database::diagnostic_conn`] could not open the file read-only
121    /// (T5.1, D-091).
122    ///
123    /// Its own variant rather than `NotFound`, which renders "node {0} not
124    /// found" — naming the wrong subject is the defect [D-069] was written to
125    /// correct, and a file is not a node.
126    ///
127    /// The case worth the sentence is a missing file:
128    /// `SQLITE_OPEN_READ_ONLY` drops `SQLITE_OPEN_CREATE` with it, so a path
129    /// that does not exist is `SQLITE_CANTOPEN` rather than a fresh empty
130    /// database. That is the right behaviour and an opaque error to receive.
131    ///
132    /// [D-069]: ../../docs/architecture/s13-decision-register.md#d-069
133    #[error("cannot open {path} read-only for diagnostics: {reason}")]
134    DiagnosticConn { path: String, reason: String },
135    /// [`crate::Database::archive_windowed`] was given a window it cannot use
136    /// (T1.1, D-080).
137    ///
138    /// Carries a `reason` rather than the numbers as fields because the two
139    /// cases it covers are not the same shape — a zero-length window never
140    /// advances at all, while a merely narrow one produces a session count that
141    /// has to be quoted against the limit to mean anything. A caller reading
142    /// this needs the sentence, not the struct.
143    ///
144    /// It is an error rather than a silent clamp on purpose. Rounding a
145    /// one-second window up to something workable would archive over boundaries
146    /// the caller did not choose, and the caller cannot see that it happened.
147    #[error("archive window {window:?} is unusable: {reason}")]
148    ArchiveWindow {
149        window: std::time::Duration,
150        reason: String,
151    },
152
153    /// A timestamp that is not in canonical form (§4.1, D-029).
154    ///
155    /// **Distinct from [`Self::ReplayCorrupt`], which is what this used to be
156    /// (Wave 4.5).** `timestamp::normalize` and `timestamp::parse` reported bad
157    /// *caller input* as `ReplayCorrupt { seq: 0 }` — a claim that the ledger is
158    /// damaged, carrying a sequence number that cannot exist because
159    /// `AUTOINCREMENT` starts at 1. The same mistake as defect J: an error that
160    /// names the wrong subject sends a caller to fix the wrong thing.
161    ///
162    /// The value is reported rather than the provenance, because one function
163    /// serves both directions — a caller passing `2026-01-01T00:00:00Z` and a
164    /// stored `recorded_at` that will not parse produce the same complaint about
165    /// the same string. `SystemClock::new` is where the second case is
166    /// interpreted, and it already logs and floors to the wall clock (D-027).
167    #[error("timestamp {value:?} is not canonical: {reason}")]
168    InvalidTimestamp { value: String, reason: String },
169
170    /// An identifier the crate's own encodings cannot represent (D-061).
171    ///
172    /// Distinct from [`Self::NotFound`], and the distinction is defect J: this
173    /// id was refused, not looked up. `validate_id` used to return `NotFound`
174    /// here, which tells a caller the thing is missing and invites them to
175    /// create it — with the same id, which will be refused again.
176    #[error("invalid identifier {id:?}: {reason}")]
177    InvalidId { id: String, reason: String },
178
179    /// Two valid-time intervals for one relationship claim the same instant.
180    ///
181    /// Distinct from [`Self::SingleOpenViolation`], which is the storage layer's
182    /// guard and covers only the *open* sentinel. This is the general case, and
183    /// it is refused at the API rather than by a trigger (D-060): raw SQL against
184    /// the same file can still write an overlap, and §4.2 says so.
185    ///
186    /// The consequence of allowing one is not an error later but a wrong answer:
187    /// `query_as_of_edges` at an instant inside both returns the relationship
188    /// twice, and every weighted algorithm downstream double-counts that edge.
189    ///
190    /// **Boxed, and it is the only variant that is (D-075).** Seven `String`s is
191    /// 168 bytes, which made `DbError` — and therefore every `Result` in the
192    /// crate, on the `Ok` path too — larger than `clippy::result_large_err`'s
193    /// threshold the moment D-060 added it. The other variants are well under.
194    /// Boxing the rarest one keeps the whole error small rather than trimming
195    /// what a caller is told; `matches!(err, OverlappingInterval { .. })` is
196    /// unaffected, which is how every call site uses it.
197    #[error(
198        "edge {} -> {} ({}) already holds [{}, {}), which overlaps the asserted [{}, {})",
199        .overlap.source_id, .overlap.target_id, .overlap.edge_type,
200        .overlap.existing_from, .overlap.existing_to,
201        .overlap.valid_from, .overlap.valid_to
202    )]
203    OverlappingInterval { overlap: Box<Overlap> },
204
205    #[error("links_current drift detected: {n} intervals diverge")]
206    CurrentDrift { n: usize },
207
208    #[error("rebuild verification failed: {n} intervals still diverge")]
209    RebuildFailed { n: usize },
210    /// A chunked shadow rebuild was abandoned rather than committed (T1.2, D-082).
211    ///
212    /// Distinct from [`Self::RebuildFailed`], and the distinction is the whole
213    /// point: `RebuildFailed` means the repair ran and did not repair, which is
214    /// a reason to distrust the ledger. This means the repair **did not run** —
215    /// something invalidated the work in progress and it was discarded before it
216    /// could be swapped in. `links_current` is untouched and whatever was true
217    /// of it before is still true. The action is to retry.
218    #[error("chunked rebuild abandoned: {reason}")]
219    RebuildInterrupted { reason: String },
220
221    // -- 0.4.5: writer-actor containment --
222    #[error("write actor is not running (reopen the Database)")]
223    WriterUnavailable,
224
225    #[error("write actor dropped the response channel mid-request")]
226    WriterDroppedResponder,
227
228    /// The actor's task did not join cleanly at [`crate::Database::close`].
229    ///
230    /// Distinct from [`Self::WriterUnavailable`], which means the channel is
231    /// gone while the handle is still in use. This is the shutdown path telling
232    /// a caller that the write actor panicked — which `close()` used to swallow,
233    /// so a database whose write path had died closed "successfully" (Wave 4.2).
234    #[error("write actor did not shut down cleanly: {0}")]
235    WriterStopped(String),
236
237    // -- 0.5.0: concept integrity --
238    #[error("recorded_at must advance on concept update (got {got}, had {had})")]
239    RecordedAtRegression { got: String, had: String },
240}
241
242pub type Result<T> = std::result::Result<T, DbError>;
243
244/// A guard abort recognised by its message (§4.3).
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum AbortKind {
247    SingleOpenInterval,
248    RecordedAtRegression,
249    DeleteOutsideArchive,
250    /// Not one of our guards — an ordinary engine error.
251    NotAGuard,
252}
253
254/// Recognise a schema guard's `RAISE(ABORT, …)` by its message.
255///
256/// **The only place in the crate that matches on engine error text.** SQLite
257/// reports a `RAISE(ABORT)` as a generic constraint failure carrying the
258/// message, so the message is the only thing distinguishing "you violated the
259/// single-open-interval rule" from "the disk is full" — but matching on it
260/// scattered across call sites means an upstream wording change degrades an
261/// unknown number of typed errors into opaque ones, silently. Concentrated here,
262/// a change breaks one function and the tests that cover it.
263///
264/// The needles are the [`crate::schema::ddl`] constants spliced into the
265/// triggers themselves, so guard and classifier cannot drift.
266pub fn abort_kind(err: &libsql::Error) -> AbortKind {
267    use crate::schema::ddl::{ABORT_DELETE_GUARD, ABORT_MONOTONIC_RA, ABORT_SINGLE_OPEN};
268
269    let text = err.to_string();
270    if text.contains(ABORT_SINGLE_OPEN) {
271        AbortKind::SingleOpenInterval
272    } else if text.contains(ABORT_MONOTONIC_RA) {
273        AbortKind::RecordedAtRegression
274    } else if text.contains(ABORT_DELETE_GUARD) {
275        AbortKind::DeleteOutsideArchive
276    } else {
277        AbortKind::NotAGuard
278    }
279}
280
281/// What a failing statement was trying to do, so a guard abort can name it.
282pub enum WriteOp<'a> {
283    Edge {
284        source_id: &'a str,
285        target_id: &'a str,
286        edge_type: &'a str,
287    },
288    Concept {
289        id: &'a str,
290        recorded_at: &'a str,
291    },
292    Delete {
293        table: &'a str,
294    },
295}
296
297/// Turn an engine error into the typed error §7 specifies, where one applies.
298///
299/// Takes a connection because `RecordedAtRegression` reports the value it
300/// clashed with, and the trigger does not put it in the message. One extra query
301/// on an error path buys an error a caller can act on instead of one they have
302/// to reproduce by hand.
303pub async fn classify(conn: &libsql::Connection, err: libsql::Error, op: WriteOp<'_>) -> DbError {
304    match (abort_kind(&err), op) {
305        (
306            AbortKind::SingleOpenInterval,
307            WriteOp::Edge {
308                source_id,
309                target_id,
310                edge_type,
311            },
312        ) => DbError::SingleOpenViolation {
313            source_id: source_id.to_string(),
314            target_id: target_id.to_string(),
315            edge_type: edge_type.to_string(),
316        },
317        (AbortKind::RecordedAtRegression, WriteOp::Concept { id, recorded_at }) => {
318            let had = current_recorded_at(conn, id).await.unwrap_or_default();
319            DbError::RecordedAtRegression {
320                got: recorded_at.to_string(),
321                had,
322            }
323        }
324        (AbortKind::DeleteOutsideArchive, WriteOp::Delete { table }) => DbError::ArchiveViolation {
325            table: table.to_string(),
326        },
327        // A guard fired for an operation it does not describe. Reporting the raw
328        // error is honest; inventing a typed one from the wrong context is not.
329        _ => DbError::Engine(err),
330    }
331}
332
333async fn current_recorded_at(conn: &libsql::Connection, id: &str) -> Option<String> {
334    conn.query(
335        "SELECT recorded_at FROM concepts WHERE id = ?1",
336        libsql::params![id],
337    )
338    .await
339    .ok()?
340    .next()
341    .await
342    .ok()??
343    .get(0)
344    .ok()
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    /// `DbError` stays under `clippy::result_large_err`'s 128-byte threshold.
352    ///
353    /// Every fallible function in the crate returns `Result<T, DbError>`, so the
354    /// enum's size is paid on the `Ok` path too. D-060 pushed it to 168 bytes with
355    /// one seven-`String` variant and nobody noticed until D-075 read the lint
356    /// output; boxing that variant brought it back. This is the tripwire, because
357    /// the failure mode is a warning in a build log rather than a broken test —
358    /// the kind this cycle has spent its whole length finding.
359    #[test]
360    fn the_error_enum_stays_small_enough_to_return_by_value() {
361        let size = std::mem::size_of::<DbError>();
362        assert!(
363            size <= 128,
364            "DbError is {size} bytes. Some variant has grown past what a Result \n             should carry — box it, as OverlappingInterval is boxed (D-075)."
365        );
366    }
367
368    /// The boxed variant still reports both intervals.
369    #[test]
370    fn an_overlap_names_the_asserted_interval_and_the_stored_one() {
371        let err = DbError::OverlappingInterval {
372            overlap: Box::new(Overlap {
373                source_id: "a".into(),
374                target_id: "b".into(),
375                edge_type: "KNOWS".into(),
376                valid_from: "2026-03-01T00:00:00.000000Z".into(),
377                valid_to: "2026-09-01T00:00:00.000000Z".into(),
378                existing_from: "2026-01-01T00:00:00.000000Z".into(),
379                existing_to: "2026-06-01T00:00:00.000000Z".into(),
380            }),
381        };
382        let msg = err.to_string();
383        assert!(msg.contains("a -> b (KNOWS)"), "{msg}");
384        assert!(msg.contains("already holds [2026-01-01"), "{msg}");
385        assert!(msg.contains("asserted [2026-03-01"), "{msg}");
386    }
387}