Skip to main content

helios_persistence/core/
versioned.rs

1//! Versioned storage trait.
2//!
3//! This module extends [`ResourceStorage`] with version-aware operations,
4//! including version reads (vread) and optimistic locking with If-Match.
5
6use async_trait::async_trait;
7use serde_json::Value;
8
9use crate::error::{ConcurrencyError, StorageError, StorageResult};
10use crate::tenant::TenantContext;
11use crate::types::StoredResource;
12
13use super::storage::ResourceStorage;
14
15/// Storage trait with version-aware operations.
16///
17/// This trait extends [`ResourceStorage`] with capabilities for reading specific
18/// versions of resources and performing updates with optimistic locking.
19///
20/// # Versioning Model
21///
22/// Each resource has a version ID that is incremented on every update. The version
23/// ID is a monotonically increasing string (typically a number). The first version
24/// of a resource has version ID "1".
25///
26/// # Optimistic Locking
27///
28/// The `update_with_match` method implements HTTP If-Match semantics. The update
29/// only succeeds if the current version matches the expected version. This prevents
30/// lost updates in concurrent scenarios.
31///
32/// # Example
33///
34/// ```ignore
35/// use helios_persistence::core::VersionedStorage;
36///
37/// async fn example<S: VersionedStorage>(storage: &S) -> Result<(), StorageError> {
38///     let tenant = TenantContext::new(
39///         TenantId::new("acme"),
40///         TenantPermissions::full_access(),
41///     );
42///
43///     // Read a specific version
44///     let v1 = storage.vread(&tenant, "Patient", "123", "1").await?;
45///
46///     // Update with optimistic locking
47///     if let Some(current) = storage.read(&tenant, "Patient", "123").await? {
48///         let new_content = serde_json::json!({"name": [{"family": "Updated"}]});
49///         let updated = storage.update_with_match(
50///             &tenant,
51///             "Patient",
52///             "123",
53///             current.version_id(),
54///             new_content,
55///         ).await?;
56///     }
57///
58///     Ok(())
59/// }
60/// ```
61#[async_trait]
62pub trait VersionedStorage: ResourceStorage {
63    /// Reads a specific version of a resource (vread).
64    ///
65    /// This corresponds to the FHIR vread interaction:
66    /// `GET [base]/[type]/[id]/_history/[vid]`
67    ///
68    /// # Arguments
69    ///
70    /// * `tenant` - The tenant context for this operation
71    /// * `resource_type` - The FHIR resource type
72    /// * `id` - The resource's logical ID
73    /// * `version_id` - The version ID to read
74    ///
75    /// # Returns
76    ///
77    /// The stored resource at the specified version, or `None` if not found.
78    /// Note that this returns the resource even if it was subsequently deleted,
79    /// as long as the specific version exists.
80    ///
81    /// # Errors
82    ///
83    /// * `StorageError::Tenant` - If the tenant doesn't have read permission
84    async fn vread(
85        &self,
86        tenant: &TenantContext,
87        resource_type: &str,
88        id: &str,
89        version_id: &str,
90    ) -> StorageResult<Option<StoredResource>>;
91
92    /// Updates a resource with optimistic locking (If-Match).
93    ///
94    /// The update only succeeds if the current version matches `expected_version`.
95    /// This implements HTTP If-Match semantics for concurrent update protection.
96    ///
97    /// # Arguments
98    ///
99    /// * `tenant` - The tenant context for this operation
100    /// * `resource_type` - The FHIR resource type
101    /// * `id` - The resource's logical ID
102    /// * `expected_version` - The expected current version (from ETag/version_id)
103    /// * `resource` - The new resource content
104    ///
105    /// # Returns
106    ///
107    /// The updated resource with incremented version.
108    ///
109    /// # Errors
110    ///
111    /// * `StorageError::Resource(NotFound)` - If the resource doesn't exist
112    /// * `StorageError::Concurrency(VersionConflict)` - If versions don't match
113    /// * `StorageError::Concurrency(OptimisticLockFailure)` - If update races with another
114    /// * `StorageError::Tenant` - If the tenant doesn't have update permission
115    async fn update_with_match(
116        &self,
117        tenant: &TenantContext,
118        resource_type: &str,
119        id: &str,
120        expected_version: &str,
121        resource: Value,
122    ) -> StorageResult<StoredResource>;
123
124    /// Deletes a resource with optimistic locking (If-Match).
125    ///
126    /// The delete only succeeds if the current version matches `expected_version`.
127    ///
128    /// # Arguments
129    ///
130    /// * `tenant` - The tenant context for this operation
131    /// * `resource_type` - The FHIR resource type
132    /// * `id` - The resource's logical ID
133    /// * `expected_version` - The expected current version
134    ///
135    /// # Errors
136    ///
137    /// * `StorageError::Resource(NotFound)` - If the resource doesn't exist
138    /// * `StorageError::Concurrency(VersionConflict)` - If versions don't match
139    /// * `StorageError::Tenant` - If the tenant doesn't have delete permission
140    async fn delete_with_match(
141        &self,
142        tenant: &TenantContext,
143        resource_type: &str,
144        id: &str,
145        expected_version: &str,
146    ) -> StorageResult<()>;
147
148    /// Gets the current version ID of a resource without reading the full content.
149    ///
150    /// This is more efficient than `read` when you only need the version.
151    ///
152    /// # Arguments
153    ///
154    /// * `tenant` - The tenant context for this operation
155    /// * `resource_type` - The FHIR resource type
156    /// * `id` - The resource's logical ID
157    ///
158    /// # Returns
159    ///
160    /// The current version ID, or `None` if the resource doesn't exist or is deleted.
161    async fn current_version(
162        &self,
163        tenant: &TenantContext,
164        resource_type: &str,
165        id: &str,
166    ) -> StorageResult<Option<String>> {
167        Ok(self
168            .read(tenant, resource_type, id)
169            .await?
170            .map(|r| r.version_id().to_string()))
171    }
172
173    /// Lists all version IDs for a resource.
174    ///
175    /// # Arguments
176    ///
177    /// * `tenant` - The tenant context for this operation
178    /// * `resource_type` - The FHIR resource type
179    /// * `id` - The resource's logical ID
180    ///
181    /// # Returns
182    ///
183    /// A vector of version IDs in ascending order (oldest first).
184    async fn list_versions(
185        &self,
186        tenant: &TenantContext,
187        resource_type: &str,
188        id: &str,
189    ) -> StorageResult<Vec<String>>;
190}
191
192/// Information about a version conflict.
193#[derive(Debug, Clone)]
194pub struct VersionConflictInfo {
195    /// The resource type.
196    pub resource_type: String,
197    /// The resource ID.
198    pub id: String,
199    /// The version that was expected.
200    pub expected_version: String,
201    /// The actual current version.
202    pub actual_version: String,
203    /// The current resource content (if available).
204    pub current_content: Option<Value>,
205}
206
207impl VersionConflictInfo {
208    /// Creates a new version conflict info.
209    pub fn new(
210        resource_type: impl Into<String>,
211        id: impl Into<String>,
212        expected_version: impl Into<String>,
213        actual_version: impl Into<String>,
214    ) -> Self {
215        Self {
216            resource_type: resource_type.into(),
217            id: id.into(),
218            expected_version: expected_version.into(),
219            actual_version: actual_version.into(),
220            current_content: None,
221        }
222    }
223
224    /// Adds the current content to the conflict info.
225    pub fn with_content(mut self, content: Value) -> Self {
226        self.current_content = Some(content);
227        self
228    }
229
230    /// Converts this info into a storage error.
231    pub fn into_error(self) -> StorageError {
232        StorageError::Concurrency(ConcurrencyError::VersionConflict {
233            resource_type: self.resource_type,
234            id: self.id,
235            expected_version: self.expected_version,
236            actual_version: self.actual_version,
237        })
238    }
239}
240
241/// Helper function to check version match.
242///
243/// Returns `Ok(())` if versions match, or an error if they don't.
244pub fn check_version_match(
245    resource_type: &str,
246    id: &str,
247    expected: &str,
248    actual: &str,
249) -> StorageResult<()> {
250    if expected == actual {
251        Ok(())
252    } else {
253        Err(VersionConflictInfo::new(resource_type, id, expected, actual).into_error())
254    }
255}
256
257/// Helper function to normalize ETag values for comparison.
258///
259/// ETags may be formatted as `W/"1"`, `"1"`, or just `1`.
260/// This function extracts the version number for comparison.
261pub fn normalize_etag(etag: &str) -> &str {
262    etag.trim_start_matches("W/")
263        .trim_start_matches('"')
264        .trim_end_matches('"')
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_version_conflict_info() {
273        let info = VersionConflictInfo::new("Patient", "123", "1", "2");
274        assert_eq!(info.resource_type, "Patient");
275        assert_eq!(info.id, "123");
276        assert_eq!(info.expected_version, "1");
277        assert_eq!(info.actual_version, "2");
278    }
279
280    #[test]
281    fn test_version_conflict_with_content() {
282        let info = VersionConflictInfo::new("Patient", "123", "1", "2")
283            .with_content(serde_json::json!({"name": "test"}));
284        assert!(info.current_content.is_some());
285    }
286
287    #[test]
288    fn test_version_conflict_into_error() {
289        let info = VersionConflictInfo::new("Patient", "123", "1", "2");
290        let error = info.into_error();
291        assert!(matches!(error, StorageError::Concurrency(_)));
292    }
293
294    #[test]
295    fn test_check_version_match_success() {
296        let result = check_version_match("Patient", "123", "1", "1");
297        assert!(result.is_ok());
298    }
299
300    #[test]
301    fn test_check_version_match_failure() {
302        let result = check_version_match("Patient", "123", "1", "2");
303        assert!(result.is_err());
304    }
305
306    #[test]
307    fn test_normalize_etag() {
308        assert_eq!(normalize_etag("W/\"1\""), "1");
309        assert_eq!(normalize_etag("\"1\""), "1");
310        assert_eq!(normalize_etag("1"), "1");
311        assert_eq!(normalize_etag("W/\"abc\""), "abc");
312    }
313}