pub(crate) mod rejection;
pub(crate) mod top;
use core::fmt;
use std::sync::Arc;
pub(crate) use rejection::Rejection;
pub use rejection::{LabelField, ScaledAmount, Stage};
pub(crate) use top::Error;
pub use crate::core::{AuditFailure, AuditFailureKind};
pub use crate::model::{AmountError, BuildError, Field, ValueError};
use time::Date;
use crate::model::{AccountPath, Currency, EntryId, LedgerId};
use crate::store::{StoreError, StoreErrorKind};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum QueryError {
#[error("the window starts on {start}, after its end {end}")]
InvalidWindow {
start: Date,
end: Date,
},
#[error("no period has the id {id}")]
UnknownPeriod {
id: String,
},
#[error("no account {account} is registered")]
UnknownAccount {
account: String,
},
#[error("{text:?} is not a valid label")]
InvalidLabel {
text: String,
},
#[error("no entry has the id {id}")]
UnknownEntry {
id: EntryId,
},
#[error("the period {period} is not sealed")]
NotSealed {
period: String,
},
#[error("position {position} is beyond the head of size {size}")]
PositionBeyondHead {
position: u64,
size: u64,
},
#[error("no consistency proof from size {old} to size {new}")]
InvalidSizes {
old: u64,
new: u64,
},
#[error("an amount cannot hold {context}")]
Overflow {
context: OverflowContext,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum OverflowContext {
#[error("the totals in {0}")]
Totals(Currency),
#[error("the subtree total of {0}")]
Subtree(AccountPath),
}
#[derive(Clone)]
pub struct BackendError {
source: Arc<dyn std::error::Error + Send + Sync>,
transient: bool,
may_have_applied: bool,
}
impl BackendError {
pub fn permanent(source: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
source: Arc::new(source),
transient: false,
may_have_applied: false,
}
}
pub fn transient(source: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
source: Arc::new(source),
transient: true,
may_have_applied: false,
}
}
#[must_use]
pub fn outcome_unknown(self) -> Self {
Self {
may_have_applied: true,
..self
}
}
#[must_use]
pub const fn is_transient(&self) -> bool {
self.transient
}
#[must_use]
pub const fn may_have_applied(&self) -> bool {
self.may_have_applied
}
}
impl fmt::Debug for BackendError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BackendError")
.field("source", &self.source)
.field("transient", &self.transient)
.field("may_have_applied", &self.may_have_applied)
.finish()
}
}
impl fmt::Display for BackendError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.source)?;
if self.transient {
f.write_str(" (transient)")?;
}
if self.may_have_applied {
f.write_str(" (may have applied)")?;
}
Ok(())
}
}
impl std::error::Error for BackendError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&*self.source)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntegrityError {
kind: IntegrityKind,
position: Option<u64>,
audit: Option<Box<AuditFailure>>,
}
impl IntegrityError {
#[must_use]
pub const fn new(kind: IntegrityKind, position: Option<u64>) -> Self {
Self {
kind,
position,
audit: None,
}
}
#[must_use]
pub const fn kind(&self) -> IntegrityKind {
self.kind
}
#[must_use]
pub const fn position(&self) -> Option<u64> {
self.position
}
}
impl fmt::Display for IntegrityError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.audit {
Some(audit) => write!(f, "{}", audit.kind),
None => write!(f, "{}", self.kind),
}
}
}
impl std::error::Error for IntegrityError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.audit
.as_deref()
.map(|audit| audit as &(dyn std::error::Error + 'static))
}
}
impl From<AuditFailure> for IntegrityError {
fn from(failure: AuditFailure) -> Self {
let kind = match &failure.kind {
AuditFailureKind::Undecodable(_)
| AuditFailureKind::NonCanonical
| AuditFailureKind::GenesisMissing => IntegrityKind::Undecodable,
AuditFailureKind::GenesisMismatch => IntegrityKind::GenesisMismatch,
AuditFailureKind::SealMismatch { .. } | AuditFailureKind::SealTotalsDecrease { .. } => {
IntegrityKind::SealMismatch
}
AuditFailureKind::HeadMismatch => IntegrityKind::HeadMismatch,
AuditFailureKind::ProjectionMismatch { part, .. } => part.integrity_kind(),
AuditFailureKind::PositionMismatch { .. } => IntegrityKind::MissingRecord,
AuditFailureKind::WouldReject(_)
| AuditFailureKind::NotProduced
| AuditFailureKind::HandleMismatch { .. }
| AuditFailureKind::MemoMismatch
| AuditFailureKind::FingerprintMismatch => IntegrityKind::ReplayMismatch,
};
Self {
kind,
position: failure.position,
audit: Some(Box::new(failure)),
}
}
}
impl From<StoreError> for Error {
fn from(error: StoreError) -> Self {
match error.kind() {
StoreErrorKind::Integrity => {
IntegrityError::new(error.integrity_kind(), error.position()).into()
}
StoreErrorKind::SchemaMismatch { found } => Self::Config(ConfigError::SchemaMismatch {
found: found.clone(),
}),
StoreErrorKind::LedgerMismatch => Self::Config(ConfigError::LedgerMismatch {
expected: None,
found: None,
}),
_ => {
let applied = error.may_have_applied();
let backend = if error.is_transient() {
BackendError::transient(error)
} else {
BackendError::permanent(error)
};
Self::Backend(if applied {
backend.outcome_unknown()
} else {
backend
})
}
}
}
}
impl From<AuditFailure> for Error {
fn from(failure: AuditFailure) -> Self {
Self::Integrity(failure.into())
}
}
impl From<BuildError> for Error {
fn from(error: BuildError) -> Self {
Self::Rejected(crate::core::build_rejection(&error))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, thiserror::Error)]
#[non_exhaustive]
pub enum IntegrityKind {
#[error("the tree head does not match the leaves")]
HeadMismatch,
#[error("a stored tree node does not match the leaves")]
NodeMismatch,
#[error("a record is missing")]
MissingRecord,
#[error("a stored record does not decode")]
Undecodable,
#[error("a maintained balance does not match the replay")]
BalanceMismatch,
#[error("a seal does not match the replay")]
SealMismatch,
#[error("a clearing does not match the replay")]
ClearingMismatch,
#[error("a hold does not match the replay")]
HoldMismatch,
#[error("the entry count does not match the replay")]
EntryCountMismatch,
#[error("the genesis record does not match the ledger")]
GenesisMismatch,
#[error("a record or index the rule core would not have produced")]
ReplayMismatch,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("{}", ledger_mismatch(expected.as_deref(), found.as_deref()))]
LedgerMismatch {
expected: Option<String>,
found: Option<String>,
},
#[error(
"the database holds the schema {found:?}, not {:?}",
crate::store::SCHEMA_ID
)]
SchemaMismatch {
found: String,
},
}
impl ConfigError {
pub(crate) fn ledgers(expected: (&LedgerId, u8), found: (&LedgerId, u8)) -> Self {
Self::LedgerMismatch {
expected: Some(format!("{} (P = {})", expected.0, expected.1)),
found: Some(format!("{} (P = {})", found.0, found.1)),
}
}
}
fn ledger_mismatch(expected: Option<&str>, found: Option<&str>) -> String {
match (expected, found) {
(Some(expected), Some(found)) => {
format!("the store holds the ledger {found}, not {expected}")
}
_ => String::from("the store holds another ledger or scale"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::AuditFailureKind as K;
use crate::model::Layer;
use crate::store::ProjectionPart as Part;
fn kind_of(kind: K) -> IntegrityKind {
IntegrityError::from(AuditFailure {
position: Some(3),
kind,
})
.kind()
}
#[test]
fn audit_failures_map_to_their_integrity_kind() {
let label = crate::model::Label::new("2026-03").unwrap_or_else(|_| unreachable!());
let cases = [
(K::NonCanonical, IntegrityKind::Undecodable),
(K::GenesisMissing, IntegrityKind::Undecodable),
(K::GenesisMismatch, IntegrityKind::GenesisMismatch),
(
K::SealMismatch { period: label },
IntegrityKind::SealMismatch,
),
(
K::SealTotalsDecrease {
currency: crate::model::EUR,
layer: Layer::Settled,
},
IntegrityKind::SealMismatch,
),
(K::HeadMismatch, IntegrityKind::HeadMismatch),
(
K::PositionMismatch { expected: 4 },
IntegrityKind::MissingRecord,
),
(K::NotProduced, IntegrityKind::ReplayMismatch),
(K::MemoMismatch, IntegrityKind::ReplayMismatch),
(K::FingerprintMismatch, IntegrityKind::ReplayMismatch),
];
for (failure, expected) in cases {
assert_eq!(kind_of(failure.clone()), expected, "{failure}");
}
let parts = [
(Part::Meta, IntegrityKind::GenesisMismatch),
(Part::Nodes, IntegrityKind::NodeMismatch),
(Part::Balances, IntegrityKind::BalanceMismatch),
(Part::Holds, IntegrityKind::HoldMismatch),
(Part::Outstanding, IntegrityKind::HoldMismatch),
(Part::ClearingResets, IntegrityKind::ClearingMismatch),
(Part::SealNodes, IntegrityKind::SealMismatch),
(Part::Entries, IntegrityKind::EntryCountMismatch),
(Part::Accounts, IntegrityKind::ReplayMismatch),
];
for (part, expected) in parts {
let failure = K::ProjectionMismatch {
part,
expected: None,
found: None,
};
assert_eq!(kind_of(failure), expected, "{part}");
}
}
#[test]
fn debug_shows_the_code_and_the_message() {
let error = Error::from(Rejection::TooFewPostings { count: 1 });
assert_eq!(
format!("{error:?}"),
"rejected: [101] an entry needs at least two postings, found 1"
);
assert!(format!("{error:#?}").starts_with("Rejected("));
assert_eq!(error.code(), Some(101));
let config = Error::from(ConfigError::SchemaMismatch {
found: String::from("other/1"),
});
assert_eq!(
format!("{config:?}"),
"configuration: the database holds the schema \"other/1\", not \"doubleentry/1\""
);
assert_eq!(config.code(), None);
}
}