use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Overlap {
pub source_id: String,
pub target_id: String,
pub edge_type: String,
pub valid_from: String,
pub valid_to: String,
pub existing_from: String,
pub existing_to: String,
}
#[derive(Debug, Error)]
pub enum DbError {
#[error("engine: {0}")]
Engine(#[from] libsql::Error),
#[error("migration to v{to} failed: {reason}")]
Migration { to: u32, reason: String },
#[error("invalid edge type {0} (must match [A-Z0-9]+)")]
InvalidEdgeType(String),
#[error(
"{source_id} -> {target_id} ({edge_type}) already has an open interval; retire it first"
)]
SingleOpenViolation {
source_id: String,
target_id: String,
edge_type: String,
},
#[error("node {0} not found")]
NotFound(String),
#[error("embedding dim {got}, expected {expected} for model {model}")]
DimMismatch {
got: usize,
expected: usize,
model: String,
},
#[error("invalid embedding model name {0:?}: expected [a-z][a-z0-9_]* up to 48 characters")]
InvalidModelName(String),
#[error("embedding model {model} is not registered (no {table} table)")]
ModelNotRegistered { model: String, table: String },
#[error("subgraph exceeds budget ({n} > {budget})")]
SubgraphTooLarge { n: usize, budget: usize },
#[error("edge {source_id} -> {target_id} has weight {weight}, which shortest-path analytics cannot use")]
NegativeEdgeWeight {
source_id: String,
target_id: String,
weight: f64,
},
#[error("replay corrupt at seq {seq}: {reason}")]
ReplayCorrupt { seq: i64, reason: String },
#[error("snapshot {path} is not readable by this build: {reason}")]
SnapshotIncompatible { path: String, reason: String },
#[error("payload v{got} unsupported (max {max})")]
PayloadVersion { got: u8, max: u8 },
#[error("physical delete blocked outside archive session ({table})")]
ArchiveViolation { table: String },
#[error(
"traversal as_of({as_of}) did not state an attribute mode: topology at \
{as_of} would be returned with attributes as they are *now*. Call \
.attribute_mode(AttributeMode::AtTime) for attributes as believed at \
{as_of}, or .attribute_mode(AttributeMode::Current) to confirm live \
attributes are intended"
)]
AttributeModeUnstated { as_of: String },
#[error("cannot open {path} read-only for diagnostics: {reason}")]
DiagnosticConn { path: String, reason: String },
#[error("archive window {window:?} is unusable: {reason}")]
ArchiveWindow {
window: std::time::Duration,
reason: String,
},
#[error("timestamp {value:?} is not canonical: {reason}")]
InvalidTimestamp { value: String, reason: String },
#[error("invalid identifier {id:?}: {reason}")]
InvalidId { id: String, reason: String },
#[error(
"edge {} -> {} ({}) already holds [{}, {}), which overlaps the asserted [{}, {})",
.overlap.source_id, .overlap.target_id, .overlap.edge_type,
.overlap.existing_from, .overlap.existing_to,
.overlap.valid_from, .overlap.valid_to
)]
OverlappingInterval { overlap: Box<Overlap> },
#[error("links_current drift detected: {n} intervals diverge")]
CurrentDrift { n: usize },
#[error("rebuild verification failed: {n} intervals still diverge")]
RebuildFailed { n: usize },
#[error("chunked rebuild abandoned: {reason}")]
RebuildInterrupted { reason: String },
#[error("write actor is not running (reopen the Database)")]
WriterUnavailable,
#[error("write actor dropped the response channel mid-request")]
WriterDroppedResponder,
#[error("write actor did not shut down cleanly: {0}")]
WriterStopped(String),
#[error("recorded_at must advance on concept update (got {got}, had {had})")]
RecordedAtRegression { got: String, had: String },
}
pub type Result<T> = std::result::Result<T, DbError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AbortKind {
SingleOpenInterval,
RecordedAtRegression,
DeleteOutsideArchive,
NotAGuard,
}
pub fn abort_kind(err: &libsql::Error) -> AbortKind {
use crate::schema::ddl::{ABORT_DELETE_GUARD, ABORT_MONOTONIC_RA, ABORT_SINGLE_OPEN};
let text = err.to_string();
if text.contains(ABORT_SINGLE_OPEN) {
AbortKind::SingleOpenInterval
} else if text.contains(ABORT_MONOTONIC_RA) {
AbortKind::RecordedAtRegression
} else if text.contains(ABORT_DELETE_GUARD) {
AbortKind::DeleteOutsideArchive
} else {
AbortKind::NotAGuard
}
}
pub enum WriteOp<'a> {
Edge {
source_id: &'a str,
target_id: &'a str,
edge_type: &'a str,
},
Concept {
id: &'a str,
recorded_at: &'a str,
},
Delete {
table: &'a str,
},
}
pub async fn classify(conn: &libsql::Connection, err: libsql::Error, op: WriteOp<'_>) -> DbError {
match (abort_kind(&err), op) {
(
AbortKind::SingleOpenInterval,
WriteOp::Edge {
source_id,
target_id,
edge_type,
},
) => DbError::SingleOpenViolation {
source_id: source_id.to_string(),
target_id: target_id.to_string(),
edge_type: edge_type.to_string(),
},
(AbortKind::RecordedAtRegression, WriteOp::Concept { id, recorded_at }) => {
let had = current_recorded_at(conn, id).await.unwrap_or_default();
DbError::RecordedAtRegression {
got: recorded_at.to_string(),
had,
}
}
(AbortKind::DeleteOutsideArchive, WriteOp::Delete { table }) => DbError::ArchiveViolation {
table: table.to_string(),
},
_ => DbError::Engine(err),
}
}
async fn current_recorded_at(conn: &libsql::Connection, id: &str) -> Option<String> {
conn.query(
"SELECT recorded_at FROM concepts WHERE id = ?1",
libsql::params![id],
)
.await
.ok()?
.next()
.await
.ok()??
.get(0)
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_error_enum_stays_small_enough_to_return_by_value() {
let size = std::mem::size_of::<DbError>();
assert!(
size <= 128,
"DbError is {size} bytes. Some variant has grown past what a Result \n should carry — box it, as OverlappingInterval is boxed (D-075)."
);
}
#[test]
fn an_overlap_names_the_asserted_interval_and_the_stored_one() {
let err = DbError::OverlappingInterval {
overlap: Box::new(Overlap {
source_id: "a".into(),
target_id: "b".into(),
edge_type: "KNOWS".into(),
valid_from: "2026-03-01T00:00:00.000000Z".into(),
valid_to: "2026-09-01T00:00:00.000000Z".into(),
existing_from: "2026-01-01T00:00:00.000000Z".into(),
existing_to: "2026-06-01T00:00:00.000000Z".into(),
}),
};
let msg = err.to_string();
assert!(msg.contains("a -> b (KNOWS)"), "{msg}");
assert!(msg.contains("already holds [2026-01-01"), "{msg}");
assert!(msg.contains("asserted [2026-03-01"), "{msg}");
}
}