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