helios_persistence/core/storage.rs
1//! Core resource storage trait.
2//!
3//! This module defines the [`ResourceStorage`] trait, which provides the fundamental
4//! CRUD operations for FHIR resources. All storage operations require a [`TenantContext`]
5//! to ensure proper tenant isolation.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use helios_fhir::FhirVersion;
11use serde_json::Value;
12
13use crate::core::sof_runner::SofRunner;
14use crate::error::{StorageError, StorageResult};
15use crate::tenant::TenantContext;
16use crate::types::StoredResource;
17
18/// Audit event helpers for purge operations.
19#[cfg(feature = "audit")]
20pub mod audit {
21 use helios_audit::{AuditAction, AuditEventBuilder, AuditSink};
22
23 /// Record an audit event for a purge (permanent deletion) operation.
24 pub async fn record_purge_event(
25 sink: &dyn AuditSink,
26 source_observer: &str,
27 agent: Option<&str>,
28 resource_type: &str,
29 resource_id: Option<&str>,
30 count: u64,
31 patient_ref: Option<&str>,
32 ) {
33 let mut builder = AuditEventBuilder::new(source_observer)
34 .event_type(
35 "http://terminology.hl7.org/CodeSystem/audit-event-type",
36 "object",
37 )
38 .action(AuditAction::Delete)
39 .outcome("0")
40 .detail("audit-operation", "purge")
41 .detail("count", count.to_string());
42 if let Some(id) = resource_id {
43 builder = builder.resource(resource_type, id);
44 } else {
45 builder = builder.detail("resource-type", resource_type);
46 }
47 if let Some(a) = agent {
48 builder = builder.agent(a, None, true);
49 }
50 if let Some(p) = patient_ref {
51 builder = builder.patient(p);
52 }
53 sink.record(builder.build()).await;
54 }
55
56 #[cfg(test)]
57 mod tests {
58 use helios_audit::{AuditAction, AuditEventBuilder};
59
60 #[test]
61 fn test_purge_event_action_is_delete() {
62 let event = AuditEventBuilder::new("Device/hfs")
63 .event_type(
64 "http://terminology.hl7.org/CodeSystem/audit-event-type",
65 "object",
66 )
67 .action(AuditAction::Delete)
68 .outcome("0")
69 .detail("audit-operation", "purge")
70 .resource("Patient", "123")
71 .detail("count", "1")
72 .build();
73 assert_eq!(
74 event.action.as_ref().and_then(|a| a.value.as_deref()),
75 Some("D")
76 );
77 }
78
79 #[test]
80 fn test_purge_event_includes_count() {
81 let event = AuditEventBuilder::new("Device/hfs")
82 .event_type(
83 "http://terminology.hl7.org/CodeSystem/audit-event-type",
84 "object",
85 )
86 .action(AuditAction::Delete)
87 .outcome("0")
88 .detail("audit-operation", "purge")
89 .resource("Observation", "obs-1")
90 .detail("count", "15")
91 .build();
92 let details = event.entity.as_ref().unwrap()[0].detail.as_ref().unwrap();
93 let count_detail = details
94 .iter()
95 .find(|d| d.r#type.value.as_deref() == Some("count"));
96 assert!(count_detail.is_some());
97 }
98
99 #[test]
100 fn test_purge_with_patient_has_patient_entity() {
101 let event = AuditEventBuilder::new("Device/hfs")
102 .action(AuditAction::Delete)
103 .outcome("0")
104 .resource("Observation", "obs-1")
105 .patient("Patient/456")
106 .build();
107 let entities = event.entity.as_ref().unwrap();
108 assert_eq!(entities.len(), 2);
109 assert_eq!(
110 entities[1]
111 .what
112 .as_ref()
113 .and_then(|w| w.reference.as_ref())
114 .and_then(|s| s.value.as_deref()),
115 Some("Patient/456")
116 );
117 }
118 }
119}
120
121/// Core storage trait for FHIR resources.
122///
123/// This trait defines the fundamental CRUD (Create, Read, Update, Delete) operations
124/// for persisting FHIR resources. All operations require a [`TenantContext`] to ensure
125/// proper tenant isolation - there is no escape hatch.
126///
127/// # Tenant Isolation
128///
129/// Every operation takes a `TenantContext` as its first parameter. This design ensures
130/// that tenant isolation is enforced at the type level - it's impossible to perform
131/// storage operations without specifying the tenant context.
132///
133/// # Versioning
134///
135/// All mutating operations (create, update, delete) create new versions of resources.
136/// The version ID is monotonically increasing and is used for optimistic locking via
137/// the `If-Match` HTTP header.
138///
139/// # Soft Deletes
140///
141/// The `delete` operation performs a soft delete by default, marking the resource as
142/// deleted but retaining its history. Use `purge` for permanent deletion (if supported).
143///
144/// # Example
145///
146/// ```ignore
147/// use helios_fhir::FhirVersion;
148/// use helios_persistence::core::ResourceStorage;
149/// use helios_persistence::tenant::{TenantContext, TenantId, TenantPermissions};
150///
151/// async fn example<S: ResourceStorage>(storage: &S) -> Result<(), StorageError> {
152/// let tenant = TenantContext::new(
153/// TenantId::new("acme"),
154/// TenantPermissions::full_access(),
155/// );
156///
157/// // Create a new patient with FHIR R4
158/// let patient = serde_json::json!({
159/// "resourceType": "Patient",
160/// "name": [{"family": "Smith"}]
161/// });
162/// let stored = storage.create(&tenant, "Patient", patient, FhirVersion::default()).await?;
163/// println!("Created: {}", stored.url());
164///
165/// // Read it back
166/// let read = storage.read(&tenant, "Patient", stored.id()).await?;
167/// assert!(read.is_some());
168///
169/// // Update it
170/// let mut updated_content = stored.content().clone();
171/// updated_content["active"] = serde_json::json!(true);
172/// let updated = storage.update(&tenant, &stored, updated_content).await?;
173/// assert_eq!(updated.version_id(), "2");
174///
175/// // Delete it
176/// storage.delete(&tenant, "Patient", stored.id()).await?;
177///
178/// Ok(())
179/// }
180/// ```
181#[async_trait]
182pub trait ResourceStorage: Send + Sync {
183 /// Returns a human-readable name for this storage backend.
184 fn backend_name(&self) -> &'static str;
185
186 /// Creates a new resource.
187 ///
188 /// # Arguments
189 ///
190 /// * `tenant` - The tenant context for this operation
191 /// * `resource_type` - The FHIR resource type (e.g., "Patient")
192 /// * `resource` - The resource content as JSON
193 /// * `fhir_version` - The FHIR specification version for this resource
194 ///
195 /// # Returns
196 ///
197 /// The stored resource with assigned ID, version, and metadata.
198 ///
199 /// # Errors
200 ///
201 /// * `StorageError::Validation` - If the resource is invalid
202 /// * `StorageError::Resource(AlreadyExists)` - If a resource with the same ID exists
203 /// * `StorageError::Tenant` - If the tenant doesn't have create permission
204 async fn create(
205 &self,
206 tenant: &TenantContext,
207 resource_type: &str,
208 resource: Value,
209 fhir_version: FhirVersion,
210 ) -> StorageResult<StoredResource>;
211
212 /// Creates a resource with a specific ID (PUT semantics).
213 ///
214 /// If the resource doesn't exist, creates it with version "1".
215 /// If it exists, this is equivalent to an update.
216 ///
217 /// # Arguments
218 ///
219 /// * `tenant` - The tenant context for this operation
220 /// * `resource_type` - The FHIR resource type
221 /// * `id` - The desired resource ID
222 /// * `resource` - The resource content as JSON
223 /// * `fhir_version` - The FHIR specification version for this resource
224 ///
225 /// # Returns
226 ///
227 /// A tuple of (StoredResource, created: bool) where created indicates
228 /// whether a new resource was created (true) or an existing one updated (false).
229 async fn create_or_update(
230 &self,
231 tenant: &TenantContext,
232 resource_type: &str,
233 id: &str,
234 resource: Value,
235 fhir_version: FhirVersion,
236 ) -> StorageResult<(StoredResource, bool)>;
237
238 /// Reads a resource by type and ID.
239 ///
240 /// # Arguments
241 ///
242 /// * `tenant` - The tenant context for this operation
243 /// * `resource_type` - The FHIR resource type
244 /// * `id` - The resource's logical ID
245 ///
246 /// # Returns
247 ///
248 /// The stored resource if found and not deleted, or `None`.
249 ///
250 /// # Errors
251 ///
252 /// * `StorageError::Tenant` - If the tenant doesn't have read permission
253 /// * `StorageError::Resource(Gone)` - If the resource was deleted (optional behavior)
254 async fn read(
255 &self,
256 tenant: &TenantContext,
257 resource_type: &str,
258 id: &str,
259 ) -> StorageResult<Option<StoredResource>>;
260
261 /// Updates an existing resource.
262 ///
263 /// # Arguments
264 ///
265 /// * `tenant` - The tenant context for this operation
266 /// * `current` - The current version of the resource (for optimistic locking)
267 /// * `resource` - The new resource content
268 ///
269 /// # Returns
270 ///
271 /// The updated resource with incremented version.
272 ///
273 /// # Errors
274 ///
275 /// * `StorageError::Resource(NotFound)` - If the resource doesn't exist
276 /// * `StorageError::Concurrency(VersionConflict)` - If the resource was modified
277 /// * `StorageError::Tenant` - If the tenant doesn't have update permission
278 async fn update(
279 &self,
280 tenant: &TenantContext,
281 current: &StoredResource,
282 resource: Value,
283 ) -> StorageResult<StoredResource>;
284
285 /// Deletes a resource (soft delete).
286 ///
287 /// The resource is marked as deleted but its history is preserved.
288 /// Subsequent reads will return `None` (or `Gone` error depending on config).
289 ///
290 /// # Arguments
291 ///
292 /// * `tenant` - The tenant context for this operation
293 /// * `resource_type` - The FHIR resource type
294 /// * `id` - The resource's logical ID
295 ///
296 /// # Errors
297 ///
298 /// * `StorageError::Resource(NotFound)` - If the resource doesn't exist
299 /// * `StorageError::Resource(Gone)` - If already deleted
300 /// * `StorageError::Tenant` - If the tenant doesn't have delete permission
301 async fn delete(
302 &self,
303 tenant: &TenantContext,
304 resource_type: &str,
305 id: &str,
306 ) -> StorageResult<()>;
307
308 /// Checks if a resource exists.
309 ///
310 /// This is more efficient than `read` when you only need to check existence.
311 ///
312 /// # Arguments
313 ///
314 /// * `tenant` - The tenant context for this operation
315 /// * `resource_type` - The FHIR resource type
316 /// * `id` - The resource's logical ID
317 ///
318 /// # Returns
319 ///
320 /// `true` if the resource exists and is not deleted, `false` otherwise.
321 async fn exists(
322 &self,
323 tenant: &TenantContext,
324 resource_type: &str,
325 id: &str,
326 ) -> StorageResult<bool> {
327 Ok(self.read(tenant, resource_type, id).await?.is_some())
328 }
329
330 /// Reads multiple resources by their IDs.
331 ///
332 /// This is more efficient than multiple individual reads.
333 ///
334 /// # Arguments
335 ///
336 /// * `tenant` - The tenant context for this operation
337 /// * `resource_type` - The FHIR resource type
338 /// * `ids` - The resource IDs to read
339 ///
340 /// # Returns
341 ///
342 /// A vector of found resources (missing/deleted resources are omitted).
343 async fn read_batch(
344 &self,
345 tenant: &TenantContext,
346 resource_type: &str,
347 ids: &[&str],
348 ) -> StorageResult<Vec<StoredResource>> {
349 let mut results = Vec::with_capacity(ids.len());
350 for id in ids {
351 if let Some(resource) = self.read(tenant, resource_type, id).await? {
352 results.push(resource);
353 }
354 }
355 Ok(results)
356 }
357
358 /// Counts the total number of resources of a given type.
359 ///
360 /// # Arguments
361 ///
362 /// * `tenant` - The tenant context for this operation
363 /// * `resource_type` - The FHIR resource type (or None for all types)
364 ///
365 /// # Returns
366 ///
367 /// The count of non-deleted resources.
368 async fn count(
369 &self,
370 tenant: &TenantContext,
371 resource_type: Option<&str>,
372 ) -> StorageResult<u64>;
373
374 /// Returns the SQL-on-FHIR runner for this backend, if it supports in-DB execution.
375 ///
376 /// Backends that can compile ViewDefinitions to SQL and run them natively (SQLite,
377 /// PostgreSQL) return `Some(runner)`. All other backends return `None`, causing the
378 /// handler layer to fall back to the in-process runner.
379 ///
380 /// The default implementation returns `None`.
381 fn sof_runner(&self) -> Option<Arc<dyn SofRunner>> {
382 None
383 }
384}
385
386/// Extension trait for storage backends that support permanent deletion.
387#[async_trait]
388pub trait PurgableStorage: ResourceStorage {
389 /// Permanently deletes a resource and all its history.
390 ///
391 /// This is an irreversible operation. Use with caution.
392 ///
393 /// # Arguments
394 ///
395 /// * `tenant` - The tenant context for this operation
396 /// * `resource_type` - The FHIR resource type
397 /// * `id` - The resource's logical ID
398 ///
399 /// # Errors
400 ///
401 /// * `StorageError::Resource(NotFound)` - If the resource doesn't exist
402 /// * `StorageError::Tenant` - If the tenant doesn't have purge permission
403 async fn purge(
404 &self,
405 tenant: &TenantContext,
406 resource_type: &str,
407 id: &str,
408 ) -> StorageResult<()>;
409
410 /// Permanently deletes all resources of a type for a tenant.
411 ///
412 /// This is an irreversible operation. Use with extreme caution.
413 async fn purge_all(&self, tenant: &TenantContext, resource_type: &str) -> StorageResult<u64>;
414}
415
416/// Result of a conditional create operation.
417#[derive(Debug, Clone)]
418pub enum ConditionalCreateResult {
419 /// Resource was created (no match found).
420 Created(StoredResource),
421 /// An existing resource matched the condition.
422 Exists(StoredResource),
423 /// Multiple resources matched (error condition).
424 MultipleMatches(usize),
425}
426
427/// Result of a conditional update operation.
428#[derive(Debug, Clone)]
429pub enum ConditionalUpdateResult {
430 /// Resource was updated.
431 Updated(StoredResource),
432 /// Resource was created (no match found, upsert mode).
433 Created(StoredResource),
434 /// No resource matched the condition.
435 NoMatch,
436 /// Multiple resources matched (error condition).
437 MultipleMatches(usize),
438}
439
440/// Result of a conditional delete operation.
441#[derive(Debug, Clone)]
442pub enum ConditionalDeleteResult {
443 /// Resource was deleted.
444 Deleted,
445 /// No resource matched the condition.
446 NoMatch,
447 /// Multiple resources matched (error condition).
448 MultipleMatches(usize),
449}
450
451/// Result of a conditional patch operation.
452#[derive(Debug, Clone)]
453#[allow(clippy::large_enum_variant)]
454pub enum ConditionalPatchResult {
455 /// Resource was patched successfully.
456 Patched(StoredResource),
457 /// No resource matched the condition.
458 NoMatch,
459 /// Multiple resources matched (error condition).
460 MultipleMatches(usize),
461}
462
463/// Patch format for conditional patch operations.
464#[derive(Debug, Clone)]
465pub enum PatchFormat {
466 /// JSON Patch (RFC 6902) - application/json-patch+json
467 ///
468 /// Example:
469 /// ```json
470 /// [
471 /// {"op": "replace", "path": "/name/0/family", "value": "NewName"},
472 /// {"op": "add", "path": "/active", "value": true}
473 /// ]
474 /// ```
475 JsonPatch(Value),
476
477 /// FHIRPath Patch - application/fhir+json with Parameters resource
478 ///
479 /// Uses a Parameters resource with operation parts containing:
480 /// - type: add, insert, delete, replace, move
481 /// - path: FHIRPath expression
482 /// - name: element name (for add)
483 /// - value: new value
484 FhirPathPatch(Value),
485
486 /// JSON Merge Patch (RFC 7386) - application/merge-patch+json
487 ///
488 /// Simpler format where the patch document mirrors the structure
489 /// of the resource with only changed fields.
490 MergePatch(Value),
491}
492
493/// Extension trait for conditional operations based on search criteria.
494#[async_trait]
495pub trait ConditionalStorage: ResourceStorage {
496 /// Creates a resource only if no matching resource exists.
497 ///
498 /// # Arguments
499 ///
500 /// * `tenant` - The tenant context
501 /// * `resource_type` - The FHIR resource type
502 /// * `resource` - The resource to create
503 /// * `search_params` - Search parameters to check for existing match
504 /// * `fhir_version` - The FHIR specification version for this resource
505 ///
506 /// # Returns
507 ///
508 /// * `Created` - If no match was found and resource was created
509 /// * `Exists` - If exactly one matching resource was found
510 /// * `MultipleMatches` - If multiple matching resources were found (error)
511 async fn conditional_create(
512 &self,
513 tenant: &TenantContext,
514 resource_type: &str,
515 resource: Value,
516 search_params: &str,
517 fhir_version: FhirVersion,
518 ) -> StorageResult<ConditionalCreateResult>;
519
520 /// Updates a resource based on search criteria.
521 ///
522 /// # Arguments
523 ///
524 /// * `tenant` - The tenant context
525 /// * `resource_type` - The FHIR resource type
526 /// * `resource` - The new resource content
527 /// * `search_params` - Search parameters to find the resource
528 /// * `upsert` - If true, create if no match found
529 /// * `fhir_version` - The FHIR specification version for this resource (used if creating)
530 ///
531 /// # Returns
532 ///
533 /// * `Updated` - If exactly one match was found and updated
534 /// * `Created` - If no match was found and upsert is true
535 /// * `NoMatch` - If no match was found and upsert is false
536 /// * `MultipleMatches` - If multiple matches were found (error)
537 async fn conditional_update(
538 &self,
539 tenant: &TenantContext,
540 resource_type: &str,
541 resource: Value,
542 search_params: &str,
543 upsert: bool,
544 fhir_version: FhirVersion,
545 ) -> StorageResult<ConditionalUpdateResult>;
546
547 /// Deletes a resource based on search criteria.
548 ///
549 /// # Arguments
550 ///
551 /// * `tenant` - The tenant context
552 /// * `resource_type` - The FHIR resource type
553 /// * `search_params` - Search parameters to find the resource
554 ///
555 /// # Returns
556 ///
557 /// * `Deleted` - If exactly one match was found and deleted
558 /// * `NoMatch` - If no match was found
559 /// * `MultipleMatches` - If multiple matches were found (error)
560 async fn conditional_delete(
561 &self,
562 tenant: &TenantContext,
563 resource_type: &str,
564 search_params: &str,
565 ) -> StorageResult<ConditionalDeleteResult>;
566
567 /// Patches a resource based on search criteria.
568 ///
569 /// This implements conditional patch as defined in FHIR:
570 /// `PATCH [base]/[type]?[search-params]`
571 ///
572 /// # Arguments
573 ///
574 /// * `tenant` - The tenant context
575 /// * `resource_type` - The FHIR resource type
576 /// * `search_params` - Search parameters to find the resource
577 /// * `patch` - The patch to apply (JSON Patch, FHIRPath Patch, or Merge Patch)
578 ///
579 /// # Returns
580 ///
581 /// * `Patched` - If exactly one match was found and patched
582 /// * `NoMatch` - If no match was found
583 /// * `MultipleMatches` - If multiple matches were found (error)
584 ///
585 /// # Errors
586 ///
587 /// * `StorageError::Validation` - If the patch is invalid or would create invalid resource
588 /// * `StorageError::Backend(NotSupported)` - If conditional patch is not supported
589 async fn conditional_patch(
590 &self,
591 tenant: &TenantContext,
592 resource_type: &str,
593 search_params: &str,
594 patch: &PatchFormat,
595 ) -> StorageResult<ConditionalPatchResult> {
596 // Default implementation returns NotSupported
597 let _ = (tenant, resource_type, search_params, patch);
598 Err(StorageError::Backend(
599 crate::error::BackendError::UnsupportedCapability {
600 backend_name: "unknown".to_string(),
601 capability: "conditional_patch".to_string(),
602 },
603 ))
604 }
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 #[test]
612 fn test_conditional_create_result_debug() {
613 let result = ConditionalCreateResult::MultipleMatches(3);
614 let debug = format!("{:?}", result);
615 assert!(debug.contains("MultipleMatches"));
616 assert!(debug.contains("3"));
617 }
618
619 #[test]
620 fn test_conditional_update_result_variants() {
621 let _created = ConditionalUpdateResult::Created(StoredResource::new(
622 "Patient",
623 "123",
624 crate::tenant::TenantId::new("t1"),
625 serde_json::json!({}),
626 FhirVersion::default(),
627 ));
628 let _no_match = ConditionalUpdateResult::NoMatch;
629 let _multiple = ConditionalUpdateResult::MultipleMatches(2);
630 }
631
632 #[test]
633 fn test_conditional_delete_result_variants() {
634 let _deleted = ConditionalDeleteResult::Deleted;
635 let _no_match = ConditionalDeleteResult::NoMatch;
636 let _multiple = ConditionalDeleteResult::MultipleMatches(5);
637 }
638}