1use std::fmt;
8
9use thiserror::Error;
10
11use crate::tenant::TenantId;
12
13#[derive(Error, Debug)]
18pub enum StorageError {
19 #[error(transparent)]
21 Resource(#[from] ResourceError),
22
23 #[error(transparent)]
25 Concurrency(#[from] ConcurrencyError),
26
27 #[error(transparent)]
29 Tenant(#[from] TenantError),
30
31 #[error(transparent)]
33 Validation(#[from] ValidationError),
34
35 #[error(transparent)]
37 Search(#[from] SearchError),
38
39 #[error(transparent)]
41 Transaction(#[from] TransactionError),
42
43 #[error(transparent)]
45 Backend(#[from] BackendError),
46
47 #[error(transparent)]
49 BulkExport(#[from] BulkExportError),
50
51 #[error(transparent)]
53 BulkSubmit(#[from] BulkSubmitError),
54}
55
56#[derive(Error, Debug)]
58pub enum ResourceError {
59 #[error("resource not found: {resource_type}/{id}")]
61 NotFound {
62 resource_type: String,
64 id: String,
66 },
67
68 #[error("resource already exists: {resource_type}/{id}")]
70 AlreadyExists {
71 resource_type: String,
73 id: String,
75 },
76
77 #[error("resource deleted: {resource_type}/{id}")]
79 Gone {
80 resource_type: String,
82 id: String,
84 deleted_at: Option<chrono::DateTime<chrono::Utc>>,
86 },
87
88 #[error("version not found: {resource_type}/{id}/_history/{version_id}")]
90 VersionNotFound {
91 resource_type: String,
93 id: String,
95 version_id: String,
97 },
98}
99
100#[derive(Error, Debug)]
102pub enum ConcurrencyError {
103 #[error("version conflict: expected {expected_version}, found {actual_version}")]
105 VersionConflict {
106 resource_type: String,
108 id: String,
110 expected_version: String,
112 actual_version: String,
114 },
115
116 #[error("optimistic lock failure: resource {resource_type}/{id} has been modified")]
118 OptimisticLockFailure {
119 resource_type: String,
121 id: String,
123 expected_etag: String,
125 actual_etag: Option<String>,
127 },
128
129 #[error("deadlock detected while accessing {resource_type}/{id}")]
131 Deadlock {
132 resource_type: String,
134 id: String,
136 },
137
138 #[error("lock timeout after {timeout_ms}ms for {resource_type}/{id}")]
140 LockTimeout {
141 resource_type: String,
143 id: String,
145 timeout_ms: u64,
147 },
148}
149
150#[derive(Error, Debug)]
152pub enum TenantError {
153 #[error("access denied: tenant {tenant_id} cannot access {resource_type}/{resource_id}")]
155 AccessDenied {
156 tenant_id: TenantId,
158 resource_type: String,
160 resource_id: String,
162 },
163
164 #[error("invalid tenant: {tenant_id}")]
166 InvalidTenant {
167 tenant_id: TenantId,
169 },
170
171 #[error("tenant suspended: {tenant_id}")]
173 TenantSuspended {
174 tenant_id: TenantId,
176 },
177
178 #[error(
180 "cross-tenant reference not allowed: resource in tenant {source_tenant} references resource in tenant {target_tenant}"
181 )]
182 CrossTenantReference {
183 source_tenant: TenantId,
185 target_tenant: TenantId,
187 reference: String,
189 },
190
191 #[error("operation {operation} not permitted for tenant {tenant_id}")]
193 OperationNotPermitted {
194 tenant_id: TenantId,
196 operation: String,
198 },
199}
200
201#[derive(Error, Debug)]
203pub enum ValidationError {
204 #[error("invalid resource: {message}")]
206 InvalidResource {
207 message: String,
209 details: Vec<ValidationDetail>,
211 },
212
213 #[error("invalid search parameter: {parameter}")]
215 InvalidSearchParameter {
216 parameter: String,
218 message: String,
220 },
221
222 #[error("unsupported resource type: {resource_type}")]
224 UnsupportedResourceType {
225 resource_type: String,
227 },
228
229 #[error("missing required field: {field}")]
231 MissingRequiredField {
232 field: String,
234 },
235
236 #[error("invalid reference: {reference}")]
238 InvalidReference {
239 reference: String,
241 message: String,
243 },
244}
245
246#[derive(Debug, Clone)]
248pub struct ValidationDetail {
249 pub path: String,
251 pub message: String,
253 pub severity: ValidationSeverity,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum ValidationSeverity {
260 Error,
262 Warning,
264 Information,
266}
267
268impl fmt::Display for ValidationSeverity {
269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270 match self {
271 ValidationSeverity::Error => write!(f, "error"),
272 ValidationSeverity::Warning => write!(f, "warning"),
273 ValidationSeverity::Information => write!(f, "information"),
274 }
275 }
276}
277
278#[derive(Error, Debug)]
280pub enum SearchError {
281 #[error("unsupported search parameter type: {param_type}")]
283 UnsupportedParameterType {
284 param_type: String,
286 },
287
288 #[error("unsupported modifier '{modifier}' for parameter type '{param_type}'")]
290 UnsupportedModifier {
291 modifier: String,
293 param_type: String,
295 },
296
297 #[error("chained search not supported: {chain}")]
299 ChainedSearchNotSupported {
300 chain: String,
302 },
303
304 #[error("reverse chaining (_has) not supported")]
306 ReverseChainNotSupported,
307
308 #[error("{operation} not supported by this backend")]
310 IncludeNotSupported {
311 operation: String,
313 },
314
315 #[error("search result limit exceeded: found {count}, maximum is {max}")]
317 TooManyResults {
318 count: usize,
320 max: usize,
322 },
323
324 #[error("invalid pagination cursor: {cursor}")]
326 InvalidCursor {
327 cursor: String,
329 },
330
331 #[error("failed to parse search query: {message}")]
333 QueryParseError {
334 message: String,
336 },
337
338 #[error("invalid composite search parameter: {message}")]
340 InvalidComposite {
341 message: String,
343 },
344
345 #[error("full-text search not available")]
347 TextSearchNotAvailable,
348}
349
350#[derive(Error, Debug)]
352pub enum TransactionError {
353 #[error("transaction timed out after {timeout_ms}ms")]
355 Timeout {
356 timeout_ms: u64,
358 },
359
360 #[error("transaction rolled back: {reason}")]
362 RolledBack {
363 reason: String,
365 },
366
367 #[error("transaction no longer valid")]
369 InvalidTransaction,
370
371 #[error("nested transactions not supported")]
373 NestedNotSupported,
374
375 #[error("bundle processing error at entry {index}: {message}")]
377 BundleError {
378 index: usize,
380 message: String,
382 },
383
384 #[error("conditional {operation} matched {count} resources, expected at most 1")]
386 MultipleMatches {
387 operation: String,
389 count: usize,
391 },
392
393 #[error("isolation level {level} not supported by this backend")]
395 UnsupportedIsolationLevel {
396 level: String,
398 },
399}
400
401#[derive(Error, Debug)]
403pub enum BackendError {
404 #[error("backend unavailable: {backend_name}")]
406 Unavailable {
407 backend_name: String,
409 message: String,
411 },
412
413 #[error("connection failed to {backend_name}: {message}")]
415 ConnectionFailed {
416 backend_name: String,
418 message: String,
420 },
421
422 #[error("connection pool exhausted for {backend_name}")]
424 PoolExhausted {
425 backend_name: String,
427 },
428
429 #[error("capability '{capability}' not supported by {backend_name}")]
431 UnsupportedCapability {
432 backend_name: String,
434 capability: String,
436 },
437
438 #[error("schema migration failed: {message}")]
440 MigrationError {
441 message: String,
443 },
444
445 #[error("internal error in {backend_name}: {message}")]
447 Internal {
448 backend_name: String,
450 message: String,
452 #[source]
454 source: Option<Box<dyn std::error::Error + Send + Sync>>,
455 },
456
457 #[error("query execution failed: {message}")]
459 QueryError {
460 message: String,
462 },
463
464 #[error("serialization error: {message}")]
466 SerializationError {
467 message: String,
469 },
470}
471
472#[derive(Error, Debug)]
474pub enum BulkExportError {
475 #[error("export job not found: {job_id}")]
477 JobNotFound {
478 job_id: String,
480 },
481
482 #[error("invalid job state: job {job_id} is {actual}, expected {expected}")]
484 InvalidJobState {
485 job_id: String,
487 expected: String,
489 actual: String,
491 },
492
493 #[error("resource type '{resource_type}' is not exportable")]
495 TypeNotExportable {
496 resource_type: String,
498 },
499
500 #[error("invalid export request: {message}")]
502 InvalidRequest {
503 message: String,
505 },
506
507 #[error("group not found: {group_id}")]
509 GroupNotFound {
510 group_id: String,
512 },
513
514 #[error("unsupported export format: {format}")]
516 UnsupportedFormat {
517 format: String,
519 },
520
521 #[error("invalid type filter for {resource_type}: {message}")]
523 InvalidTypeFilter {
524 resource_type: String,
526 message: String,
528 },
529
530 #[error("export job {job_id} was cancelled")]
532 Cancelled {
533 job_id: String,
535 },
536
537 #[error("export write error: {message}")]
539 WriteError {
540 message: String,
542 },
543
544 #[error("too many concurrent exports (maximum: {max_concurrent})")]
546 TooManyConcurrentExports {
547 max_concurrent: u32,
549 },
550
551 #[error("export job {job_id} lease lost (reclaimed by another worker)")]
553 LeaseLost {
554 job_id: String,
556 },
557}
558
559#[derive(Error, Debug)]
561pub enum BulkSubmitError {
562 #[error("submission not found: {submitter}/{submission_id}")]
564 SubmissionNotFound {
565 submitter: String,
567 submission_id: String,
569 },
570
571 #[error("manifest not found: {submission_id}/{manifest_id}")]
573 ManifestNotFound {
574 submission_id: String,
576 manifest_id: String,
578 },
579
580 #[error("invalid submission state: {submission_id} is {actual}, expected {expected}")]
582 InvalidState {
583 submission_id: String,
585 expected: String,
587 actual: String,
589 },
590
591 #[error("submission {submission_id} is already complete")]
593 AlreadyComplete {
594 submission_id: String,
596 },
597
598 #[error("submission {submission_id} was aborted: {reason}")]
600 Aborted {
601 submission_id: String,
603 reason: String,
605 },
606
607 #[error("submission {submission_id} exceeded maximum errors ({max_errors})")]
609 MaxErrorsExceeded {
610 submission_id: String,
612 max_errors: u32,
614 },
615
616 #[error("parse error at line {line}: {message}")]
618 ParseError {
619 line: u64,
621 message: String,
623 },
624
625 #[error("invalid resource at line {line}: {message}")]
627 InvalidResource {
628 line: u64,
630 message: String,
632 },
633
634 #[error("duplicate submission: {submitter}/{submission_id}")]
636 DuplicateSubmission {
637 submitter: String,
639 submission_id: String,
641 },
642
643 #[error("cannot replace manifest {manifest_url}: {reason}")]
645 ManifestReplacementError {
646 manifest_url: String,
648 reason: String,
650 },
651
652 #[error("rollback failed for submission {submission_id}: {message}")]
654 RollbackFailed {
655 submission_id: String,
657 message: String,
659 },
660}
661
662pub type StorageResult<T> = Result<T, StorageError>;
664
665pub type SearchResult<T> = Result<T, SearchError>;
667
668pub type TransactionResult<T> = Result<T, TransactionError>;
670
671impl From<serde_json::Error> for StorageError {
674 fn from(err: serde_json::Error) -> Self {
675 StorageError::Backend(BackendError::SerializationError {
676 message: err.to_string(),
677 })
678 }
679}
680
681impl From<std::io::Error> for BackendError {
682 fn from(err: std::io::Error) -> Self {
683 BackendError::Internal {
684 backend_name: "unknown".to_string(),
685 message: err.to_string(),
686 source: Some(Box::new(err)),
687 }
688 }
689}
690
691#[cfg(feature = "sqlite")]
692impl From<rusqlite::Error> for StorageError {
693 fn from(err: rusqlite::Error) -> Self {
694 StorageError::Backend(BackendError::Internal {
695 backend_name: "sqlite".to_string(),
696 message: err.to_string(),
697 source: Some(Box::new(err)),
698 })
699 }
700}
701
702#[cfg(feature = "sqlite")]
703impl From<r2d2::Error> for StorageError {
704 fn from(_err: r2d2::Error) -> Self {
705 StorageError::Backend(BackendError::PoolExhausted {
706 backend_name: "sqlite".to_string(),
707 })
708 }
709}
710
711#[cfg(feature = "postgres")]
712impl From<tokio_postgres::Error> for StorageError {
713 fn from(err: tokio_postgres::Error) -> Self {
714 StorageError::Backend(BackendError::Internal {
715 backend_name: "postgres".to_string(),
716 message: err.to_string(),
717 source: Some(Box::new(err)),
718 })
719 }
720}
721
722#[cfg(feature = "mongodb")]
723impl From<mongodb::error::Error> for StorageError {
724 fn from(err: mongodb::error::Error) -> Self {
725 StorageError::Backend(BackendError::Internal {
726 backend_name: "mongodb".to_string(),
727 message: err.to_string(),
728 source: Some(Box::new(err)),
729 })
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 #[test]
738 fn test_storage_error_display() {
739 let err = StorageError::Resource(ResourceError::NotFound {
740 resource_type: "Patient".to_string(),
741 id: "123".to_string(),
742 });
743 assert_eq!(err.to_string(), "resource not found: Patient/123");
744 }
745
746 #[test]
747 fn test_concurrency_error_display() {
748 let err = ConcurrencyError::VersionConflict {
749 resource_type: "Patient".to_string(),
750 id: "123".to_string(),
751 expected_version: "1".to_string(),
752 actual_version: "2".to_string(),
753 };
754 assert_eq!(err.to_string(), "version conflict: expected 1, found 2");
755 }
756
757 #[test]
758 fn test_tenant_error_display() {
759 let err = TenantError::AccessDenied {
760 tenant_id: TenantId::new("tenant-a"),
761 resource_type: "Patient".to_string(),
762 resource_id: "123".to_string(),
763 };
764 assert!(err.to_string().contains("access denied"));
765 }
766
767 #[test]
768 fn test_search_error_display() {
769 let err = SearchError::UnsupportedModifier {
770 modifier: "contains".to_string(),
771 param_type: "token".to_string(),
772 };
773 assert!(err.to_string().contains("unsupported modifier"));
774 }
775
776 #[test]
777 fn test_validation_severity_display() {
778 assert_eq!(ValidationSeverity::Error.to_string(), "error");
779 assert_eq!(ValidationSeverity::Warning.to_string(), "warning");
780 assert_eq!(ValidationSeverity::Information.to_string(), "information");
781 }
782
783 #[test]
784 fn test_bulk_export_error_display() {
785 let err = BulkExportError::JobNotFound {
786 job_id: "abc-123".to_string(),
787 };
788 assert_eq!(err.to_string(), "export job not found: abc-123");
789
790 let err = BulkExportError::InvalidJobState {
791 job_id: "abc-123".to_string(),
792 expected: "in-progress".to_string(),
793 actual: "complete".to_string(),
794 };
795 assert!(err.to_string().contains("invalid job state"));
796 }
797
798 #[test]
799 fn test_bulk_submit_error_display() {
800 let err = BulkSubmitError::SubmissionNotFound {
801 submitter: "test-system".to_string(),
802 submission_id: "sub-123".to_string(),
803 };
804 assert_eq!(err.to_string(), "submission not found: test-system/sub-123");
805
806 let err = BulkSubmitError::ParseError {
807 line: 42,
808 message: "invalid JSON".to_string(),
809 };
810 assert!(err.to_string().contains("line 42"));
811 }
812
813 #[test]
814 fn test_storage_error_from_bulk_errors() {
815 let export_err = BulkExportError::JobNotFound {
816 job_id: "test".to_string(),
817 };
818 let storage_err: StorageError = export_err.into();
819 assert!(matches!(storage_err, StorageError::BulkExport(_)));
820
821 let submit_err = BulkSubmitError::SubmissionNotFound {
822 submitter: "test".to_string(),
823 submission_id: "123".to_string(),
824 };
825 let storage_err: StorageError = submit_err.into();
826 assert!(matches!(storage_err, StorageError::BulkSubmit(_)));
827 }
828}