Skip to main content

converge_core/traits/
store.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! # Store Capability Traits
5//!
6//! Durable context snapshot storage for applications with state spanning
7//! multiple runs.
8
9use std::future::Future;
10use std::time::Duration;
11
12use super::error::{CapabilityError, ErrorCategory};
13use crate::context::ContextState;
14
15// ============================================================================
16// Store Error
17// ============================================================================
18
19/// Error type for store operations.
20#[derive(Debug, Clone)]
21pub enum StoreError {
22    /// Storage backend is temporarily unavailable.
23    Unavailable { message: String },
24    /// Serialization or deserialization failed.
25    SerializationFailed { message: String },
26    /// ID already exists (duplicate).
27    Conflict { event_id: String },
28    /// Query was malformed or invalid.
29    InvalidQuery { message: String },
30    /// Authentication with the store backend failed.
31    AuthFailed { message: String },
32    /// Rate limit exceeded; retry after delay.
33    RateLimited { retry_after: Duration },
34    /// Operation timed out.
35    Timeout {
36        elapsed: Duration,
37        deadline: Duration,
38    },
39    /// Record not found.
40    NotFound { message: String },
41    /// Invariant violation in store (should not happen).
42    InvariantViolation { message: String },
43    /// Internal error with no specific category.
44    Internal { message: String },
45}
46
47impl std::fmt::Display for StoreError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::Unavailable { message } => write!(f, "store unavailable: {message}"),
51            Self::SerializationFailed { message } => write!(f, "serialization failed: {message}"),
52            Self::Conflict { event_id } => write!(f, "already exists: {event_id}"),
53            Self::InvalidQuery { message } => write!(f, "invalid query: {message}"),
54            Self::AuthFailed { message } => write!(f, "store auth failed: {message}"),
55            Self::RateLimited { retry_after } => {
56                write!(f, "rate limited, retry after {retry_after:?}")
57            }
58            Self::Timeout { elapsed, deadline } => {
59                write!(f, "timed out after {elapsed:?} (deadline: {deadline:?})")
60            }
61            Self::NotFound { message } => write!(f, "not found: {message}"),
62            Self::InvariantViolation { message } => write!(f, "invariant violation: {message}"),
63            Self::Internal { message } => write!(f, "internal store error: {message}"),
64        }
65    }
66}
67
68impl std::error::Error for StoreError {}
69
70impl CapabilityError for StoreError {
71    fn category(&self) -> ErrorCategory {
72        match self {
73            Self::Unavailable { .. } => ErrorCategory::Unavailable,
74            Self::SerializationFailed { .. } | Self::InvalidQuery { .. } => {
75                ErrorCategory::InvalidInput
76            }
77            Self::Conflict { .. } => ErrorCategory::Conflict,
78            Self::AuthFailed { .. } => ErrorCategory::Auth,
79            Self::RateLimited { .. } => ErrorCategory::RateLimit,
80            Self::Timeout { .. } => ErrorCategory::Timeout,
81            Self::NotFound { .. } => ErrorCategory::NotFound,
82            Self::InvariantViolation { .. } => ErrorCategory::InvariantViolation,
83            Self::Internal { .. } => ErrorCategory::Internal,
84        }
85    }
86
87    fn is_transient(&self) -> bool {
88        matches!(
89            self,
90            Self::Unavailable { .. } | Self::RateLimited { .. } | Self::Timeout { .. }
91        )
92    }
93
94    fn is_retryable(&self) -> bool {
95        matches!(
96            self,
97            Self::Unavailable { .. }
98                | Self::RateLimited { .. }
99                | Self::Timeout { .. }
100                | Self::Conflict { .. }
101        )
102    }
103
104    fn retry_after(&self) -> Option<Duration> {
105        match self {
106            Self::RateLimited { retry_after } => Some(*retry_after),
107            _ => None,
108        }
109    }
110}
111
112// ============================================================================
113// ContextStore Trait
114// ============================================================================
115
116/// Durable context snapshot storage.
117///
118/// Applications with state that spans multiple runs need a place to persist
119/// and reconstruct the engine context. This trait defines that boundary
120/// without prescribing a storage backend.
121pub trait ContextStore: Send + Sync {
122    /// Future type for loading a context snapshot.
123    type LoadFut<'a>: Future<Output = Result<Option<ContextState>, StoreError>> + Send + 'a
124    where
125        Self: 'a;
126
127    /// Future type for saving a context snapshot.
128    type SaveFut<'a>: Future<Output = Result<(), StoreError>> + Send + 'a
129    where
130        Self: 'a;
131
132    /// Load the latest snapshot for a run, tenant, or application-defined scope.
133    fn load_context<'a>(&'a self, scope_id: &'a str) -> Self::LoadFut<'a>;
134
135    /// Persist the latest snapshot for a run, tenant, or application-defined scope.
136    fn save_context<'a>(
137        &'a self,
138        scope_id: &'a str,
139        context: &'a ContextState,
140    ) -> Self::SaveFut<'a>;
141}
142
143// ============================================================================
144// Dyn-Safe Wrapper
145// ============================================================================
146
147/// Boxed future type for dyn-safe trait objects.
148pub type BoxFuture<'a, T> = std::pin::Pin<Box<dyn Future<Output = T> + Send + 'a>>;
149
150/// Dyn-safe context store for runtime polymorphism.
151pub trait DynContextStore: Send + Sync {
152    /// Load a stored context snapshot.
153    fn load_context<'a>(
154        &'a self,
155        scope_id: &'a str,
156    ) -> BoxFuture<'a, Result<Option<ContextState>, StoreError>>;
157
158    /// Save a context snapshot.
159    fn save_context<'a>(
160        &'a self,
161        scope_id: &'a str,
162        context: &'a ContextState,
163    ) -> BoxFuture<'a, Result<(), StoreError>>;
164}
165
166impl<T: ContextStore> DynContextStore for T {
167    fn load_context<'a>(
168        &'a self,
169        scope_id: &'a str,
170    ) -> BoxFuture<'a, Result<Option<ContextState>, StoreError>> {
171        Box::pin(ContextStore::load_context(self, scope_id))
172    }
173
174    fn save_context<'a>(
175        &'a self,
176        scope_id: &'a str,
177        context: &'a ContextState,
178    ) -> BoxFuture<'a, Result<(), StoreError>> {
179        Box::pin(ContextStore::save_context(self, scope_id, context))
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn store_error_display() {
189        let err = StoreError::Conflict {
190            event_id: "evt-123".to_string(),
191        };
192        assert!(err.to_string().contains("evt-123"));
193    }
194
195    #[test]
196    fn store_error_category_classification() {
197        assert_eq!(
198            StoreError::Unavailable {
199                message: "test".to_string()
200            }
201            .category(),
202            ErrorCategory::Unavailable
203        );
204        assert_eq!(
205            StoreError::Conflict {
206                event_id: "test".to_string()
207            }
208            .category(),
209            ErrorCategory::Conflict
210        );
211    }
212
213    #[test]
214    fn store_error_transient_classification() {
215        assert!(
216            StoreError::Unavailable {
217                message: "test".to_string()
218            }
219            .is_transient()
220        );
221        assert!(
222            !StoreError::Conflict {
223                event_id: "test".to_string()
224            }
225            .is_transient()
226        );
227    }
228
229    #[test]
230    fn store_error_retryable_classification() {
231        assert!(
232            StoreError::Unavailable {
233                message: "test".to_string()
234            }
235            .is_retryable()
236        );
237        assert!(
238            !StoreError::AuthFailed {
239                message: "test".to_string()
240            }
241            .is_retryable()
242        );
243    }
244
245    #[test]
246    fn store_error_retry_after() {
247        let err = StoreError::RateLimited {
248            retry_after: Duration::from_secs(60),
249        };
250        assert_eq!(err.retry_after(), Some(Duration::from_secs(60)));
251    }
252}