#![allow(missing_docs)]
use std::fmt;
use thiserror::Error;
use crate::tenant::TenantId;
#[derive(Error, Debug)]
pub enum StorageError {
#[error(transparent)]
Resource(#[from] ResourceError),
#[error(transparent)]
Concurrency(#[from] ConcurrencyError),
#[error(transparent)]
Tenant(#[from] TenantError),
#[error(transparent)]
Validation(#[from] ValidationError),
#[error(transparent)]
Search(#[from] SearchError),
#[error(transparent)]
Transaction(#[from] TransactionError),
#[error(transparent)]
Backend(#[from] BackendError),
#[error(transparent)]
BulkExport(#[from] BulkExportError),
#[error(transparent)]
BulkSubmit(#[from] BulkSubmitError),
}
#[derive(Error, Debug)]
pub enum ResourceError {
#[error("resource not found: {resource_type}/{id}")]
NotFound { resource_type: String, id: String },
#[error("resource already exists: {resource_type}/{id}")]
AlreadyExists { resource_type: String, id: String },
#[error("resource deleted: {resource_type}/{id}")]
Gone {
resource_type: String,
id: String,
deleted_at: Option<chrono::DateTime<chrono::Utc>>,
},
#[error("version not found: {resource_type}/{id}/_history/{version_id}")]
VersionNotFound {
resource_type: String,
id: String,
version_id: String,
},
}
#[derive(Error, Debug)]
pub enum ConcurrencyError {
#[error("version conflict: expected {expected_version}, found {actual_version}")]
VersionConflict {
resource_type: String,
id: String,
expected_version: String,
actual_version: String,
},
#[error("optimistic lock failure: resource {resource_type}/{id} has been modified")]
OptimisticLockFailure {
resource_type: String,
id: String,
expected_etag: String,
actual_etag: Option<String>,
},
#[error("deadlock detected while accessing {resource_type}/{id}")]
Deadlock { resource_type: String, id: String },
#[error("lock timeout after {timeout_ms}ms for {resource_type}/{id}")]
LockTimeout {
resource_type: String,
id: String,
timeout_ms: u64,
},
}
#[derive(Error, Debug)]
pub enum TenantError {
#[error("access denied: tenant {tenant_id} cannot access {resource_type}/{resource_id}")]
AccessDenied {
tenant_id: TenantId,
resource_type: String,
resource_id: String,
},
#[error("invalid tenant: {tenant_id}")]
InvalidTenant { tenant_id: TenantId },
#[error("tenant suspended: {tenant_id}")]
TenantSuspended { tenant_id: TenantId },
#[error(
"cross-tenant reference not allowed: resource in tenant {source_tenant} references resource in tenant {target_tenant}"
)]
CrossTenantReference {
source_tenant: TenantId,
target_tenant: TenantId,
reference: String,
},
#[error("operation {operation} not permitted for tenant {tenant_id}")]
OperationNotPermitted {
tenant_id: TenantId,
operation: String,
},
}
#[derive(Error, Debug)]
pub enum ValidationError {
#[error("invalid resource: {message}")]
InvalidResource {
message: String,
details: Vec<ValidationDetail>,
},
#[error("invalid search parameter: {parameter}")]
InvalidSearchParameter { parameter: String, message: String },
#[error("unsupported resource type: {resource_type}")]
UnsupportedResourceType { resource_type: String },
#[error("missing required field: {field}")]
MissingRequiredField { field: String },
#[error("invalid reference: {reference}")]
InvalidReference { reference: String, message: String },
}
#[derive(Debug, Clone)]
pub struct ValidationDetail {
pub path: String,
pub message: String,
pub severity: ValidationSeverity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationSeverity {
Error,
Warning,
Information,
}
impl fmt::Display for ValidationSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidationSeverity::Error => write!(f, "error"),
ValidationSeverity::Warning => write!(f, "warning"),
ValidationSeverity::Information => write!(f, "information"),
}
}
}
#[derive(Error, Debug)]
pub enum SearchError {
#[error("unsupported search parameter type: {param_type}")]
UnsupportedParameterType { param_type: String },
#[error("unsupported modifier '{modifier}' for parameter type '{param_type}'")]
UnsupportedModifier {
modifier: String,
param_type: String,
},
#[error("chained search not supported: {chain}")]
ChainedSearchNotSupported { chain: String },
#[error("reverse chaining (_has) not supported")]
ReverseChainNotSupported,
#[error("{operation} not supported by this backend")]
IncludeNotSupported { operation: String },
#[error("search result limit exceeded: found {count}, maximum is {max}")]
TooManyResults { count: usize, max: usize },
#[error("invalid pagination cursor: {cursor}")]
InvalidCursor { cursor: String },
#[error("failed to parse search query: {message}")]
QueryParseError { message: String },
#[error("invalid composite search parameter: {message}")]
InvalidComposite { message: String },
#[error("full-text search not available")]
TextSearchNotAvailable,
}
#[derive(Error, Debug)]
pub enum TransactionError {
#[error("transaction timed out after {timeout_ms}ms")]
Timeout { timeout_ms: u64 },
#[error("transaction rolled back: {reason}")]
RolledBack { reason: String },
#[error("transaction no longer valid")]
InvalidTransaction,
#[error("nested transactions not supported")]
NestedNotSupported,
#[error("bundle processing error at entry {index}: {message}")]
BundleError { index: usize, message: String },
#[error("conditional {operation} matched {count} resources, expected at most 1")]
MultipleMatches { operation: String, count: usize },
#[error("isolation level {level} not supported by this backend")]
UnsupportedIsolationLevel { level: String },
}
#[derive(Error, Debug)]
pub enum BackendError {
#[error("backend unavailable: {backend_name}")]
Unavailable {
backend_name: String,
message: String,
},
#[error("connection failed to {backend_name}: {message}")]
ConnectionFailed {
backend_name: String,
message: String,
},
#[error("connection pool exhausted for {backend_name}")]
PoolExhausted { backend_name: String },
#[error("capability '{capability}' not supported by {backend_name}")]
UnsupportedCapability {
backend_name: String,
capability: String,
},
#[error("schema migration failed: {message}")]
MigrationError { message: String },
#[error("internal error in {backend_name}: {message}")]
Internal {
backend_name: String,
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("query execution failed: {message}")]
QueryError { message: String },
#[error("serialization error: {message}")]
SerializationError { message: String },
}
#[derive(Error, Debug)]
pub enum BulkExportError {
#[error("export job not found: {job_id}")]
JobNotFound { job_id: String },
#[error("invalid job state: job {job_id} is {actual}, expected {expected}")]
InvalidJobState {
job_id: String,
expected: String,
actual: String,
},
#[error("resource type '{resource_type}' is not exportable")]
TypeNotExportable { resource_type: String },
#[error("invalid export request: {message}")]
InvalidRequest { message: String },
#[error("group not found: {group_id}")]
GroupNotFound { group_id: String },
#[error("unsupported export format: {format}")]
UnsupportedFormat { format: String },
#[error("invalid type filter for {resource_type}: {message}")]
InvalidTypeFilter {
resource_type: String,
message: String,
},
#[error("export job {job_id} was cancelled")]
Cancelled { job_id: String },
#[error("export write error: {message}")]
WriteError { message: String },
#[error("too many concurrent exports (maximum: {max_concurrent})")]
TooManyConcurrentExports { max_concurrent: u32 },
}
#[derive(Error, Debug)]
pub enum BulkSubmitError {
#[error("submission not found: {submitter}/{submission_id}")]
SubmissionNotFound {
submitter: String,
submission_id: String,
},
#[error("manifest not found: {submission_id}/{manifest_id}")]
ManifestNotFound {
submission_id: String,
manifest_id: String,
},
#[error("invalid submission state: {submission_id} is {actual}, expected {expected}")]
InvalidState {
submission_id: String,
expected: String,
actual: String,
},
#[error("submission {submission_id} is already complete")]
AlreadyComplete { submission_id: String },
#[error("submission {submission_id} was aborted: {reason}")]
Aborted {
submission_id: String,
reason: String,
},
#[error("submission {submission_id} exceeded maximum errors ({max_errors})")]
MaxErrorsExceeded {
submission_id: String,
max_errors: u32,
},
#[error("parse error at line {line}: {message}")]
ParseError { line: u64, message: String },
#[error("invalid resource at line {line}: {message}")]
InvalidResource { line: u64, message: String },
#[error("duplicate submission: {submitter}/{submission_id}")]
DuplicateSubmission {
submitter: String,
submission_id: String,
},
#[error("cannot replace manifest {manifest_url}: {reason}")]
ManifestReplacementError {
manifest_url: String,
reason: String,
},
#[error("rollback failed for submission {submission_id}: {message}")]
RollbackFailed {
submission_id: String,
message: String,
},
}
pub type StorageResult<T> = Result<T, StorageError>;
pub type SearchResult<T> = Result<T, SearchError>;
pub type TransactionResult<T> = Result<T, TransactionError>;
impl From<serde_json::Error> for StorageError {
fn from(err: serde_json::Error) -> Self {
StorageError::Backend(BackendError::SerializationError {
message: err.to_string(),
})
}
}
impl From<std::io::Error> for BackendError {
fn from(err: std::io::Error) -> Self {
BackendError::Internal {
backend_name: "unknown".to_string(),
message: err.to_string(),
source: Some(Box::new(err)),
}
}
}
#[cfg(feature = "sqlite")]
impl From<rusqlite::Error> for StorageError {
fn from(err: rusqlite::Error) -> Self {
StorageError::Backend(BackendError::Internal {
backend_name: "sqlite".to_string(),
message: err.to_string(),
source: Some(Box::new(err)),
})
}
}
#[cfg(feature = "sqlite")]
impl From<r2d2::Error> for StorageError {
fn from(_err: r2d2::Error) -> Self {
StorageError::Backend(BackendError::PoolExhausted {
backend_name: "sqlite".to_string(),
})
}
}
#[cfg(feature = "postgres")]
impl From<tokio_postgres::Error> for StorageError {
fn from(err: tokio_postgres::Error) -> Self {
StorageError::Backend(BackendError::Internal {
backend_name: "postgres".to_string(),
message: err.to_string(),
source: Some(Box::new(err)),
})
}
}
#[cfg(feature = "mongodb")]
impl From<mongodb::error::Error> for StorageError {
fn from(err: mongodb::error::Error) -> Self {
StorageError::Backend(BackendError::Internal {
backend_name: "mongodb".to_string(),
message: err.to_string(),
source: Some(Box::new(err)),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_storage_error_display() {
let err = StorageError::Resource(ResourceError::NotFound {
resource_type: "Patient".to_string(),
id: "123".to_string(),
});
assert_eq!(err.to_string(), "resource not found: Patient/123");
}
#[test]
fn test_concurrency_error_display() {
let err = ConcurrencyError::VersionConflict {
resource_type: "Patient".to_string(),
id: "123".to_string(),
expected_version: "1".to_string(),
actual_version: "2".to_string(),
};
assert_eq!(err.to_string(), "version conflict: expected 1, found 2");
}
#[test]
fn test_tenant_error_display() {
let err = TenantError::AccessDenied {
tenant_id: TenantId::new("tenant-a"),
resource_type: "Patient".to_string(),
resource_id: "123".to_string(),
};
assert!(err.to_string().contains("access denied"));
}
#[test]
fn test_search_error_display() {
let err = SearchError::UnsupportedModifier {
modifier: "contains".to_string(),
param_type: "token".to_string(),
};
assert!(err.to_string().contains("unsupported modifier"));
}
#[test]
fn test_validation_severity_display() {
assert_eq!(ValidationSeverity::Error.to_string(), "error");
assert_eq!(ValidationSeverity::Warning.to_string(), "warning");
assert_eq!(ValidationSeverity::Information.to_string(), "information");
}
#[test]
fn test_bulk_export_error_display() {
let err = BulkExportError::JobNotFound {
job_id: "abc-123".to_string(),
};
assert_eq!(err.to_string(), "export job not found: abc-123");
let err = BulkExportError::InvalidJobState {
job_id: "abc-123".to_string(),
expected: "in-progress".to_string(),
actual: "complete".to_string(),
};
assert!(err.to_string().contains("invalid job state"));
}
#[test]
fn test_bulk_submit_error_display() {
let err = BulkSubmitError::SubmissionNotFound {
submitter: "test-system".to_string(),
submission_id: "sub-123".to_string(),
};
assert_eq!(err.to_string(), "submission not found: test-system/sub-123");
let err = BulkSubmitError::ParseError {
line: 42,
message: "invalid JSON".to_string(),
};
assert!(err.to_string().contains("line 42"));
}
#[test]
fn test_storage_error_from_bulk_errors() {
let export_err = BulkExportError::JobNotFound {
job_id: "test".to_string(),
};
let storage_err: StorageError = export_err.into();
assert!(matches!(storage_err, StorageError::BulkExport(_)));
let submit_err = BulkSubmitError::SubmissionNotFound {
submitter: "test".to_string(),
submission_id: "123".to_string(),
};
let storage_err: StorageError = submit_err.into();
assert!(matches!(storage_err, StorageError::BulkSubmit(_)));
}
}