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,
pub within_batch: bool,
}
impl Overlap {
pub fn provenance(&self) -> &'static str {
if self.within_batch {
"this same batch also asserts"
} else {
"is already recorded"
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum StatedInstants {
Valid(String),
Recorded(String),
Both {
valid: String,
recorded: String,
},
}
impl StatedInstants {
pub fn new(valid: Option<&str>, recorded: Option<&str>) -> Option<Self> {
match (valid, recorded) {
(Some(v), Some(r)) => Some(Self::Both {
valid: v.to_string(),
recorded: r.to_string(),
}),
(Some(v), None) => Some(Self::Valid(v.to_string())),
(None, Some(r)) => Some(Self::Recorded(r.to_string())),
(None, None) => None,
}
}
pub fn valid(&self) -> Option<&str> {
match self {
Self::Valid(v) | Self::Both { valid: v, .. } => Some(v),
Self::Recorded(_) => None,
}
}
pub fn recorded(&self) -> Option<&str> {
match self {
Self::Recorded(r) | Self::Both { recorded: r, .. } => Some(r),
Self::Valid(_) => None,
}
}
}
impl std::fmt::Display for StatedInstants {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Valid(v) => write!(f, "as_of_valid({v})"),
Self::Recorded(r) => write!(f, "as_of_recorded({r})"),
Self::Both { valid, recorded } => {
write!(f, "as_of_valid({valid}) with as_of_recorded({recorded})")
}
}
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
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(
"invalid branch id {0:?}: expected 1-128 characters, \
no control characters, no leading or trailing whitespace"
)]
InvalidBranchId(String),
#[error("branch {0} is not registered")]
UnknownBranch(String),
#[error("branch {0} already exists")]
BranchExists(String),
#[error("branch {branch} cannot be archived: {reason}")]
BranchNotArchivable { branch: String, reason: String },
#[error(
"branch {branch} would fork from {parent} at {forked_at}, \
before {parent} itself was cut at {parent_forked_at}"
)]
ForkPrecedesParent {
branch: String,
parent: String,
forked_at: String,
parent_forked_at: String,
},
#[error(
"concept {id} belongs to lineage {held_by} and {attempted} may not \
restate it; a branch inherits concepts"
)]
CrossLineage {
id: String,
held_by: String,
attempted: String,
},
#[error("view of branch {view} was handed a write naming {named}")]
BranchMismatch {
view: String,
named: 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("snapshot {path} is damaged: {reason}")]
SnapshotCorrupt { path: String, reason: String },
#[error("snapshot {path} could not be saved: {reason}")]
SnapshotWriteFailed { 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(
"the archive-session marker table {marker:?} is present as committed \
state. While it exists, the delete guards on concepts, links and \
transaction_log are disarmed and concept inserts write no \
transaction_log row. An archive session creates and drops this table \
inside one transaction, so it should never be visible here — \
something wrote it outside the write actor. Drop it (DROP TABLE \
{marker}) and audit for deletions and missing log rows since it \
appeared"
)]
ArchiveSessionLeaked { marker: String },
#[error(
"traversal {instants} did not state an attribute mode: that topology \
would be returned with attributes as they are *now*. Call \
.attribute_mode(AttributeMode::AtTime) for attributes as believed at \
the stated instant, or .attribute_mode(AttributeMode::Current) to \
confirm live attributes are intended"
)]
AttributeModeUnstated { instants: StatedInstants },
#[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(
"a half-life was given without a valid-time instant to measure age from: decay ranks a hit by how old what it matched is, and \"old\" is relative to the instant the search reads at. State it — `as_of_valid(t)` on the same search — or drop the half-life"
)]
HalfLifeWithoutInstant,
#[error(
"transaction-time instant {ts} cannot be answered from the hot log: rows \
have been archived out of it and this read has no archive path. Use \
macrame::temporal::reconstruct(conn, ts, archive_path, snapshots_dir), \
which does"
)]
RecordedInstantUnreachable { ts: 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 {} -> {} ({}): the asserted [{}, {}) overlaps [{}, {}), which {}",
.overlap.source_id, .overlap.target_id, .overlap.edge_type,
.overlap.valid_from, .overlap.valid_to,
.overlap.existing_from, .overlap.existing_to,
.overlap.provenance()
)]
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 },
#[error(
"the newest recorded_at in this database is {stamp}, past the limit \
{limit}. The clock floor is taken from it, so opening would stamp \
every later write at or after it — permanently, since the next open \
reads those rows back. Set the future_stamps policy to allow to open \
it and inspect it; that inherits the floor rather than repairing it"
)]
FutureRecordedAt { stamp: String, limit: String },
#[error("the bulk write was cancelled between chunks")]
BulkCancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum ErrorKind {
Integrity,
Validation,
Vector,
Temporal,
Writer,
Budget,
Branch,
Cancelled,
Diagnostic,
Engine,
Migration,
NotFound,
}
impl ErrorKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Integrity => "integrity",
Self::Validation => "validation",
Self::Vector => "vector",
Self::Temporal => "temporal",
Self::Writer => "writer",
Self::Budget => "budget",
Self::Branch => "branch",
Self::Cancelled => "cancelled",
Self::Diagnostic => "diagnostic",
Self::Engine => "engine",
Self::Migration => "migration",
Self::NotFound => "not_found",
}
}
}
impl std::fmt::Display for ErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl DbError {
pub fn kind(&self) -> ErrorKind {
match self {
Self::Engine(_) => ErrorKind::Engine,
Self::Migration { .. } => ErrorKind::Migration,
Self::NotFound { .. } => ErrorKind::NotFound,
Self::DiagnosticConn { .. } => ErrorKind::Diagnostic,
Self::BulkCancelled => ErrorKind::Cancelled,
Self::ArchiveSessionLeaked { .. }
| Self::CurrentDrift { .. }
| Self::FutureRecordedAt { .. }
| Self::NegativeEdgeWeight { .. }
| Self::OverlappingInterval { .. }
| Self::RebuildFailed { .. }
| Self::RebuildInterrupted { .. }
| Self::RecordedAtRegression { .. }
| Self::SingleOpenViolation { .. } => ErrorKind::Integrity,
Self::AttributeModeUnstated { .. }
| Self::HalfLifeWithoutInstant
| Self::InvalidBranchId { .. }
| Self::InvalidEdgeType { .. }
| Self::InvalidId { .. }
| Self::InvalidModelName { .. }
| Self::InvalidTimestamp { .. } => ErrorKind::Validation,
Self::DimMismatch { .. } | Self::ModelNotRegistered { .. } => ErrorKind::Vector,
Self::ArchiveViolation { .. }
| Self::ArchiveWindow { .. }
| Self::PayloadVersion { .. }
| Self::RecordedInstantUnreachable { .. }
| Self::ReplayCorrupt { .. }
| Self::SnapshotCorrupt { .. }
| Self::SnapshotIncompatible { .. }
| Self::SnapshotWriteFailed { .. } => ErrorKind::Temporal,
Self::WriterDroppedResponder
| Self::WriterStopped { .. }
| Self::WriterUnavailable { .. } => ErrorKind::Writer,
Self::SubgraphTooLarge { .. } => ErrorKind::Budget,
Self::BranchExists { .. }
| Self::BranchMismatch { .. }
| Self::BranchNotArchivable { .. }
| Self::CrossLineage { .. }
| Self::ForkPrecedesParent { .. }
| Self::UnknownBranch { .. } => ErrorKind::Branch,
}
}
}
pub type Result<T> = std::result::Result<T, DbError>;
#[derive(Debug)]
pub struct BulkInterrupted {
pub written: usize,
pub cause: DbError,
}
impl std::fmt::Display for BulkInterrupted {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({} row(s) committed before the stop, and still committed)",
self.cause, self.written
)
}
}
impl std::error::Error for BulkInterrupted {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.cause)
}
}
impl From<BulkInterrupted> for DbError {
fn from(e: BulkInterrupted) -> Self {
e.cause
}
}
impl BulkInterrupted {
pub fn was_cancelled(&self) -> bool {
matches!(self.cause, DbError::BulkCancelled)
}
}
pub type BulkResult<T> = std::result::Result<T, BulkInterrupted>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AbortKind {
SingleOpenInterval,
RecordedAtRegression,
DeleteOutsideArchive,
CrossLineage,
BranchImmutable,
BranchesFrozen,
NotAGuard,
}
pub fn abort_kind(err: &libsql::Error) -> AbortKind {
use crate::schema::ddl::{
ABORT_BRANCHES_FROZEN, ABORT_BRANCH_IMMUTABLE, ABORT_CROSS_LINEAGE, 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 if text.contains(ABORT_CROSS_LINEAGE) {
AbortKind::CrossLineage
} else if text.contains(ABORT_BRANCH_IMMUTABLE) {
AbortKind::BranchImmutable
} else if text.contains(ABORT_BRANCHES_FROZEN) {
AbortKind::BranchesFrozen
} else {
AbortKind::NotAGuard
}
}
#[non_exhaustive]
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,
branch: &'a str,
},
Delete {
table: &'a str,
},
Annotation {
concept_id: &'a str,
},
}
const SQLITE_CONSTRAINT_FOREIGNKEY: std::ffi::c_int = 787;
fn is_foreign_key_violation(err: &libsql::Error) -> bool {
matches!(err, libsql::Error::SqliteFailure(code, _) if *code == SQLITE_CONSTRAINT_FOREIGNKEY)
}
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(),
},
(AbortKind::CrossLineage, WriteOp::Concept { id, branch, .. }) => DbError::CrossLineage {
id: id.to_string(),
held_by: lineage_of_concept(conn, id).await,
attempted: branch.to_string(),
},
(_, WriteOp::Annotation { concept_id }) if is_foreign_key_violation(&err) => {
DbError::NotFound(concept_id.to_string())
}
(
_,
WriteOp::Edge {
source_id,
target_id,
..
},
) if is_foreign_key_violation(&err) => {
DbError::NotFound(missing_endpoint(conn, source_id, target_id).await)
}
_ => DbError::Engine(err),
}
}
async fn lineage_of_concept(conn: &libsql::Connection, id: &str) -> String {
let unknown = || "?".to_string();
let Ok(mut rows) = conn
.query(
"SELECT branch_id FROM concepts WHERE id = ?1",
libsql::params![id],
)
.await
else {
return unknown();
};
match rows.next().await {
Ok(Some(row)) => row.get::<String>(0).unwrap_or_else(|_| unknown()),
_ => unknown(),
}
}
async fn missing_endpoint(conn: &libsql::Connection, source_id: &str, target_id: &str) -> String {
for id in [source_id, target_id] {
let Ok(mut rows) = conn
.query("SELECT 1 FROM concepts WHERE id = ?1", libsql::params![id])
.await
else {
return source_id.to_string();
};
if !matches!(rows.next().await, Ok(Some(_))) {
return id.to_string();
}
}
source_id.to_string()
}
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)."
);
}
fn sample_overlap(within_batch: bool) -> DbError {
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(),
within_batch,
}),
}
}
#[test]
fn an_overlap_names_the_asserted_interval_and_the_other_one() {
let msg = sample_overlap(false).to_string();
assert!(msg.contains("a -> b (KNOWS)"), "{msg}");
assert!(msg.contains("asserted [2026-03-01"), "{msg}");
assert!(msg.contains("[2026-01-01"), "{msg}");
}
#[test]
fn an_overlap_says_which_side_of_the_write_the_other_interval_is_on() {
let stored = sample_overlap(false).to_string();
assert!(stored.contains("is already recorded"), "{stored}");
assert!(!stored.contains("batch"), "{stored}");
let in_batch = sample_overlap(true).to_string();
assert!(
in_batch.contains("this same batch also asserts"),
"{in_batch}"
);
assert!(!in_batch.contains("recorded"), "{in_batch}");
}
#[test]
fn a_partial_bulk_failure_says_how_much_landed() {
let e = BulkInterrupted {
written: 18_935,
cause: DbError::NotFound("ghost".into()),
};
let text = e.to_string();
assert!(text.contains("node ghost not found"), "{text}");
assert!(text.contains("18935"), "{text}");
}
#[test]
fn cancellation_is_distinguishable_from_a_failure() {
assert!(BulkInterrupted {
written: 7,
cause: DbError::BulkCancelled,
}
.was_cancelled());
assert!(!BulkInterrupted {
written: 7,
cause: DbError::WriterUnavailable,
}
.was_cancelled());
}
#[test]
fn converting_to_a_db_error_keeps_the_cause_and_loses_the_count() {
let e = BulkInterrupted {
written: 400,
cause: DbError::SingleOpenViolation {
source_id: "a".into(),
target_id: "b".into(),
edge_type: "KNOWS".into(),
},
};
let cause: DbError = e.into();
assert!(matches!(cause, DbError::SingleOpenViolation { .. }));
assert!(!cause.to_string().contains("400"));
}
#[test]
fn the_cause_is_reachable_as_an_error_source() {
use std::error::Error;
let e = BulkInterrupted {
written: 1,
cause: DbError::BulkCancelled,
};
assert_eq!(
e.source().map(ToString::to_string).as_deref(),
Some("the bulk write was cancelled between chunks")
);
}
#[test]
fn the_kind_is_the_same_whatever_the_fields_say() {
let a = DbError::SnapshotWriteFailed {
path: "snapshots/1.snap.zst".into(),
reason: "no space left on device".into(),
};
let b = DbError::SnapshotWriteFailed {
path: "elsewhere".into(),
reason: "read-only file system".into(),
};
assert_eq!(a.kind(), b.kind());
assert_eq!(a.kind(), ErrorKind::Temporal);
}
#[test]
fn the_kind_is_coarser_than_the_variant_and_says_so() {
let unwritten = DbError::SnapshotWriteFailed {
path: "p".into(),
reason: "r".into(),
};
let damaged = DbError::ReplayCorrupt {
seq: 7,
reason: "r".into(),
};
assert_eq!(unwritten.kind(), damaged.kind());
assert_ne!(unwritten.to_string(), damaged.to_string());
}
#[test]
fn no_two_kinds_spell_themselves_the_same_way() {
use std::collections::BTreeSet;
let kinds = [
ErrorKind::Integrity,
ErrorKind::Validation,
ErrorKind::Vector,
ErrorKind::Temporal,
ErrorKind::Writer,
ErrorKind::Budget,
ErrorKind::Branch,
ErrorKind::Cancelled,
ErrorKind::Diagnostic,
ErrorKind::Engine,
ErrorKind::Migration,
ErrorKind::NotFound,
];
let names: BTreeSet<&str> = kinds.iter().map(|k| k.as_str()).collect();
assert_eq!(
names.len(),
kinds.len(),
"two kinds share a label: {names:?}"
);
for kind in kinds {
assert_eq!(kind.to_string(), kind.as_str(), "Display and as_str differ");
}
}
#[test]
fn a_kind_can_be_counted() {
use std::collections::HashMap;
let mut seen: HashMap<ErrorKind, usize> = HashMap::new();
for err in [
DbError::WriterStopped("a write".into()),
DbError::WriterDroppedResponder,
DbError::BulkCancelled,
] {
*seen.entry(err.kind()).or_default() += 1;
}
assert_eq!(seen[&ErrorKind::Writer], 2);
assert_eq!(seen[&ErrorKind::Cancelled], 1);
}
}