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}