1#![allow(missing_docs)]
9
10use std::fmt;
11
12use thiserror::Error;
13
14use crate::tenant::TenantId;
15
16#[derive(Error, Debug)]
21pub enum StorageError {
22 #[error(transparent)]
24 Resource(#[from] ResourceError),
25
26 #[error(transparent)]
28 Concurrency(#[from] ConcurrencyError),
29
30 #[error(transparent)]
32 Tenant(#[from] TenantError),
33
34 #[error(transparent)]
36 Validation(#[from] ValidationError),
37
38 #[error(transparent)]
40 Search(#[from] SearchError),
41
42 #[error(transparent)]
44 Transaction(#[from] TransactionError),
45
46 #[error(transparent)]
48 Backend(#[from] BackendError),
49
50 #[error(transparent)]
52 BulkExport(#[from] BulkExportError),
53
54 #[error(transparent)]
56 BulkSubmit(#[from] BulkSubmitError),
57}
58
59#[derive(Error, Debug)]
61pub enum ResourceError {
62 #[error("resource not found: {resource_type}/{id}")]
64 NotFound { resource_type: String, id: String },
65
66 #[error("resource already exists: {resource_type}/{id}")]
68 AlreadyExists { resource_type: String, id: String },
69
70 #[error("resource deleted: {resource_type}/{id}")]
72 Gone {
73 resource_type: String,
74 id: String,
75 deleted_at: Option<chrono::DateTime<chrono::Utc>>,
76 },
77
78 #[error("version not found: {resource_type}/{id}/_history/{version_id}")]
80 VersionNotFound {
81 resource_type: String,
82 id: String,
83 version_id: String,
84 },
85}
86
87#[derive(Error, Debug)]
89pub enum ConcurrencyError {
90 #[error("version conflict: expected {expected_version}, found {actual_version}")]
92 VersionConflict {
93 resource_type: String,
94 id: String,
95 expected_version: String,
96 actual_version: String,
97 },
98
99 #[error("optimistic lock failure: resource {resource_type}/{id} has been modified")]
101 OptimisticLockFailure {
102 resource_type: String,
103 id: String,
104 expected_etag: String,
105 actual_etag: Option<String>,
106 },
107
108 #[error("deadlock detected while accessing {resource_type}/{id}")]
110 Deadlock { resource_type: String, id: String },
111
112 #[error("lock timeout after {timeout_ms}ms for {resource_type}/{id}")]
114 LockTimeout {
115 resource_type: String,
116 id: String,
117 timeout_ms: u64,
118 },
119}
120
121#[derive(Error, Debug)]
123pub enum TenantError {
124 #[error("access denied: tenant {tenant_id} cannot access {resource_type}/{resource_id}")]
126 AccessDenied {
127 tenant_id: TenantId,
128 resource_type: String,
129 resource_id: String,
130 },
131
132 #[error("invalid tenant: {tenant_id}")]
134 InvalidTenant { tenant_id: TenantId },
135
136 #[error("tenant suspended: {tenant_id}")]
138 TenantSuspended { tenant_id: TenantId },
139
140 #[error(
142 "cross-tenant reference not allowed: resource in tenant {source_tenant} references resource in tenant {target_tenant}"
143 )]
144 CrossTenantReference {
145 source_tenant: TenantId,
146 target_tenant: TenantId,
147 reference: String,
148 },
149
150 #[error("operation {operation} not permitted for tenant {tenant_id}")]
152 OperationNotPermitted {
153 tenant_id: TenantId,
154 operation: String,
155 },
156}
157
158#[derive(Error, Debug)]
160pub enum ValidationError {
161 #[error("invalid resource: {message}")]
163 InvalidResource {
164 message: String,
165 details: Vec<ValidationDetail>,
166 },
167
168 #[error("invalid search parameter: {parameter}")]
170 InvalidSearchParameter { parameter: String, message: String },
171
172 #[error("unsupported resource type: {resource_type}")]
174 UnsupportedResourceType { resource_type: String },
175
176 #[error("missing required field: {field}")]
178 MissingRequiredField { field: String },
179
180 #[error("invalid reference: {reference}")]
182 InvalidReference { reference: String, message: String },
183}
184
185#[derive(Debug, Clone)]
187pub struct ValidationDetail {
188 pub path: String,
190 pub message: String,
192 pub severity: ValidationSeverity,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum ValidationSeverity {
199 Error,
201 Warning,
203 Information,
205}
206
207impl fmt::Display for ValidationSeverity {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 match self {
210 ValidationSeverity::Error => write!(f, "error"),
211 ValidationSeverity::Warning => write!(f, "warning"),
212 ValidationSeverity::Information => write!(f, "information"),
213 }
214 }
215}
216
217#[derive(Error, Debug)]
219pub enum SearchError {
220 #[error("unsupported search parameter type: {param_type}")]
222 UnsupportedParameterType { param_type: String },
223
224 #[error("unsupported modifier '{modifier}' for parameter type '{param_type}'")]
226 UnsupportedModifier {
227 modifier: String,
228 param_type: String,
229 },
230
231 #[error("chained search not supported: {chain}")]
233 ChainedSearchNotSupported { chain: String },
234
235 #[error("reverse chaining (_has) not supported")]
237 ReverseChainNotSupported,
238
239 #[error("{operation} not supported by this backend")]
241 IncludeNotSupported { operation: String },
242
243 #[error("search result limit exceeded: found {count}, maximum is {max}")]
245 TooManyResults { count: usize, max: usize },
246
247 #[error("invalid pagination cursor: {cursor}")]
249 InvalidCursor { cursor: String },
250
251 #[error("failed to parse search query: {message}")]
253 QueryParseError { message: String },
254
255 #[error("invalid composite search parameter: {message}")]
257 InvalidComposite { message: String },
258
259 #[error("full-text search not available")]
261 TextSearchNotAvailable,
262}
263
264#[derive(Error, Debug)]
266pub enum TransactionError {
267 #[error("transaction timed out after {timeout_ms}ms")]
269 Timeout { timeout_ms: u64 },
270
271 #[error("transaction rolled back: {reason}")]
273 RolledBack { reason: String },
274
275 #[error("transaction no longer valid")]
277 InvalidTransaction,
278
279 #[error("nested transactions not supported")]
281 NestedNotSupported,
282
283 #[error("bundle processing error at entry {index}: {message}")]
285 BundleError { index: usize, message: String },
286
287 #[error("conditional {operation} matched {count} resources, expected at most 1")]
289 MultipleMatches { operation: String, count: usize },
290
291 #[error("isolation level {level} not supported by this backend")]
293 UnsupportedIsolationLevel { level: String },
294}
295
296#[derive(Error, Debug)]
298pub enum BackendError {
299 #[error("backend unavailable: {backend_name}")]
301 Unavailable {
302 backend_name: String,
303 message: String,
304 },
305
306 #[error("connection failed to {backend_name}: {message}")]
308 ConnectionFailed {
309 backend_name: String,
310 message: String,
311 },
312
313 #[error("connection pool exhausted for {backend_name}")]
315 PoolExhausted { backend_name: String },
316
317 #[error("capability '{capability}' not supported by {backend_name}")]
319 UnsupportedCapability {
320 backend_name: String,
321 capability: String,
322 },
323
324 #[error("schema migration failed: {message}")]
326 MigrationError { message: String },
327
328 #[error("internal error in {backend_name}: {message}")]
330 Internal {
331 backend_name: String,
332 message: String,
333 #[source]
334 source: Option<Box<dyn std::error::Error + Send + Sync>>,
335 },
336
337 #[error("query execution failed: {message}")]
339 QueryError { message: String },
340
341 #[error("serialization error: {message}")]
343 SerializationError { message: String },
344}
345
346#[derive(Error, Debug)]
348pub enum BulkExportError {
349 #[error("export job not found: {job_id}")]
351 JobNotFound { job_id: String },
352
353 #[error("invalid job state: job {job_id} is {actual}, expected {expected}")]
355 InvalidJobState {
356 job_id: String,
357 expected: String,
358 actual: String,
359 },
360
361 #[error("resource type '{resource_type}' is not exportable")]
363 TypeNotExportable { resource_type: String },
364
365 #[error("invalid export request: {message}")]
367 InvalidRequest { message: String },
368
369 #[error("group not found: {group_id}")]
371 GroupNotFound { group_id: String },
372
373 #[error("unsupported export format: {format}")]
375 UnsupportedFormat { format: String },
376
377 #[error("invalid type filter for {resource_type}: {message}")]
379 InvalidTypeFilter {
380 resource_type: String,
381 message: String,
382 },
383
384 #[error("export job {job_id} was cancelled")]
386 Cancelled { job_id: String },
387
388 #[error("export write error: {message}")]
390 WriteError { message: String },
391
392 #[error("too many concurrent exports (maximum: {max_concurrent})")]
394 TooManyConcurrentExports { max_concurrent: u32 },
395}
396
397#[derive(Error, Debug)]
399pub enum BulkSubmitError {
400 #[error("submission not found: {submitter}/{submission_id}")]
402 SubmissionNotFound {
403 submitter: String,
404 submission_id: String,
405 },
406
407 #[error("manifest not found: {submission_id}/{manifest_id}")]
409 ManifestNotFound {
410 submission_id: String,
411 manifest_id: String,
412 },
413
414 #[error("invalid submission state: {submission_id} is {actual}, expected {expected}")]
416 InvalidState {
417 submission_id: String,
418 expected: String,
419 actual: String,
420 },
421
422 #[error("submission {submission_id} is already complete")]
424 AlreadyComplete { submission_id: String },
425
426 #[error("submission {submission_id} was aborted: {reason}")]
428 Aborted {
429 submission_id: String,
430 reason: String,
431 },
432
433 #[error("submission {submission_id} exceeded maximum errors ({max_errors})")]
435 MaxErrorsExceeded {
436 submission_id: String,
437 max_errors: u32,
438 },
439
440 #[error("parse error at line {line}: {message}")]
442 ParseError { line: u64, message: String },
443
444 #[error("invalid resource at line {line}: {message}")]
446 InvalidResource { line: u64, message: String },
447
448 #[error("duplicate submission: {submitter}/{submission_id}")]
450 DuplicateSubmission {
451 submitter: String,
452 submission_id: String,
453 },
454
455 #[error("cannot replace manifest {manifest_url}: {reason}")]
457 ManifestReplacementError {
458 manifest_url: String,
459 reason: String,
460 },
461
462 #[error("rollback failed for submission {submission_id}: {message}")]
464 RollbackFailed {
465 submission_id: String,
466 message: String,
467 },
468}
469
470pub type StorageResult<T> = Result<T, StorageError>;
472
473pub type SearchResult<T> = Result<T, SearchError>;
475
476pub type TransactionResult<T> = Result<T, TransactionError>;
478
479impl From<serde_json::Error> for StorageError {
482 fn from(err: serde_json::Error) -> Self {
483 StorageError::Backend(BackendError::SerializationError {
484 message: err.to_string(),
485 })
486 }
487}
488
489impl From<std::io::Error> for BackendError {
490 fn from(err: std::io::Error) -> Self {
491 BackendError::Internal {
492 backend_name: "unknown".to_string(),
493 message: err.to_string(),
494 source: Some(Box::new(err)),
495 }
496 }
497}
498
499#[cfg(feature = "sqlite")]
500impl From<rusqlite::Error> for StorageError {
501 fn from(err: rusqlite::Error) -> Self {
502 StorageError::Backend(BackendError::Internal {
503 backend_name: "sqlite".to_string(),
504 message: err.to_string(),
505 source: Some(Box::new(err)),
506 })
507 }
508}
509
510#[cfg(feature = "sqlite")]
511impl From<r2d2::Error> for StorageError {
512 fn from(_err: r2d2::Error) -> Self {
513 StorageError::Backend(BackendError::PoolExhausted {
514 backend_name: "sqlite".to_string(),
515 })
516 }
517}
518
519#[cfg(feature = "postgres")]
520impl From<tokio_postgres::Error> for StorageError {
521 fn from(err: tokio_postgres::Error) -> Self {
522 StorageError::Backend(BackendError::Internal {
523 backend_name: "postgres".to_string(),
524 message: err.to_string(),
525 source: Some(Box::new(err)),
526 })
527 }
528}
529
530#[cfg(feature = "mongodb")]
531impl From<mongodb::error::Error> for StorageError {
532 fn from(err: mongodb::error::Error) -> Self {
533 StorageError::Backend(BackendError::Internal {
534 backend_name: "mongodb".to_string(),
535 message: err.to_string(),
536 source: Some(Box::new(err)),
537 })
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn test_storage_error_display() {
547 let err = StorageError::Resource(ResourceError::NotFound {
548 resource_type: "Patient".to_string(),
549 id: "123".to_string(),
550 });
551 assert_eq!(err.to_string(), "resource not found: Patient/123");
552 }
553
554 #[test]
555 fn test_concurrency_error_display() {
556 let err = ConcurrencyError::VersionConflict {
557 resource_type: "Patient".to_string(),
558 id: "123".to_string(),
559 expected_version: "1".to_string(),
560 actual_version: "2".to_string(),
561 };
562 assert_eq!(err.to_string(), "version conflict: expected 1, found 2");
563 }
564
565 #[test]
566 fn test_tenant_error_display() {
567 let err = TenantError::AccessDenied {
568 tenant_id: TenantId::new("tenant-a"),
569 resource_type: "Patient".to_string(),
570 resource_id: "123".to_string(),
571 };
572 assert!(err.to_string().contains("access denied"));
573 }
574
575 #[test]
576 fn test_search_error_display() {
577 let err = SearchError::UnsupportedModifier {
578 modifier: "contains".to_string(),
579 param_type: "token".to_string(),
580 };
581 assert!(err.to_string().contains("unsupported modifier"));
582 }
583
584 #[test]
585 fn test_validation_severity_display() {
586 assert_eq!(ValidationSeverity::Error.to_string(), "error");
587 assert_eq!(ValidationSeverity::Warning.to_string(), "warning");
588 assert_eq!(ValidationSeverity::Information.to_string(), "information");
589 }
590
591 #[test]
592 fn test_bulk_export_error_display() {
593 let err = BulkExportError::JobNotFound {
594 job_id: "abc-123".to_string(),
595 };
596 assert_eq!(err.to_string(), "export job not found: abc-123");
597
598 let err = BulkExportError::InvalidJobState {
599 job_id: "abc-123".to_string(),
600 expected: "in-progress".to_string(),
601 actual: "complete".to_string(),
602 };
603 assert!(err.to_string().contains("invalid job state"));
604 }
605
606 #[test]
607 fn test_bulk_submit_error_display() {
608 let err = BulkSubmitError::SubmissionNotFound {
609 submitter: "test-system".to_string(),
610 submission_id: "sub-123".to_string(),
611 };
612 assert_eq!(err.to_string(), "submission not found: test-system/sub-123");
613
614 let err = BulkSubmitError::ParseError {
615 line: 42,
616 message: "invalid JSON".to_string(),
617 };
618 assert!(err.to_string().contains("line 42"));
619 }
620
621 #[test]
622 fn test_storage_error_from_bulk_errors() {
623 let export_err = BulkExportError::JobNotFound {
624 job_id: "test".to_string(),
625 };
626 let storage_err: StorageError = export_err.into();
627 assert!(matches!(storage_err, StorageError::BulkExport(_)));
628
629 let submit_err = BulkSubmitError::SubmissionNotFound {
630 submitter: "test".to_string(),
631 submission_id: "123".to_string(),
632 };
633 let storage_err: StorageError = submit_err.into();
634 assert!(matches!(storage_err, StorageError::BulkSubmit(_)));
635 }
636}