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}