Skip to main content

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}