Skip to main content

mockforge_vbr/
aging.rs

1//! Time-based data evolution
2//!
3//! This module handles data aging rules, automatic cleanup of expired data,
4//! and time-based field updates.
5
6use crate::Result;
7use mockforge_core::VirtualClock;
8use std::sync::Arc;
9
10/// Data aging rule
11#[derive(Debug, Clone)]
12pub struct AgingRule {
13    /// Entity name
14    pub entity_name: String,
15    /// Field to check for expiration
16    pub expiration_field: String,
17    /// Expiration duration in seconds
18    pub expiration_duration: u64,
19    /// Action to take when expired
20    pub action: AgingAction,
21}
22
23/// Action to take when data expires
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum AgingAction {
26    /// Delete the record
27    Delete,
28    /// Mark as expired (set a flag)
29    MarkExpired,
30    /// Archive (move to archive table)
31    Archive,
32}
33
34/// Data aging manager
35pub struct AgingManager {
36    /// Aging rules
37    rules: Vec<AgingRule>,
38    /// Virtual clock for time travel (optional)
39    virtual_clock: Option<Arc<VirtualClock>>,
40}
41
42impl AgingManager {
43    /// Create a new aging manager
44    pub fn new() -> Self {
45        Self {
46            rules: Vec::new(),
47            virtual_clock: None,
48        }
49    }
50
51    /// Create a new aging manager with virtual clock
52    pub fn with_virtual_clock(clock: Arc<VirtualClock>) -> Self {
53        Self {
54            rules: Vec::new(),
55            virtual_clock: Some(clock),
56        }
57    }
58
59    /// Set the virtual clock
60    pub fn set_virtual_clock(&mut self, clock: Option<Arc<VirtualClock>>) {
61        self.virtual_clock = clock;
62    }
63
64    /// Get the current time (virtual or real)
65    fn now(&self) -> chrono::DateTime<chrono::Utc> {
66        if let Some(ref clock) = self.virtual_clock {
67            clock.now()
68        } else {
69            chrono::Utc::now()
70        }
71    }
72
73    /// Add an aging rule
74    pub fn add_rule(&mut self, rule: AgingRule) {
75        self.rules.push(rule);
76    }
77
78    /// Clean up expired data
79    ///
80    /// Checks all aging rules and applies the configured action to expired records.
81    pub async fn cleanup_expired(
82        &self,
83        database: &dyn crate::database::VirtualDatabase,
84        registry: &crate::entities::EntityRegistry,
85    ) -> Result<usize> {
86        let mut total_cleaned = 0;
87
88        for rule in &self.rules {
89            // Get entity info
90            let entity = match registry.get(&rule.entity_name) {
91                Some(e) => e,
92                None => continue, // Entity not found, skip this rule
93            };
94
95            let table_name = entity.table_name();
96            let now = self.now();
97
98            // Query all records for this entity
99            let query = format!("SELECT * FROM {}", table_name);
100            let records = database.query(&query, &[]).await?;
101
102            for record in records {
103                // Check expiration field
104                if let Some(expiration_value) = record.get(&rule.expiration_field) {
105                    // Parse timestamp
106                    let expiration_time = match expiration_value {
107                        serde_json::Value::String(s) => {
108                            // Try parsing as ISO8601 timestamp
109                            match chrono::DateTime::parse_from_rfc3339(s) {
110                                Ok(dt) => dt.with_timezone(&chrono::Utc),
111                                Err(_) => continue, // Invalid timestamp, skip
112                            }
113                        }
114                        serde_json::Value::Number(n) => {
115                            // Unix timestamp
116                            if let Some(ts) = n.as_i64() {
117                                chrono::DateTime::from_timestamp(ts, 0)
118                                    .unwrap_or_else(|| self.now())
119                            } else {
120                                continue; // Invalid timestamp
121                            }
122                        }
123                        _ => continue, // Not a timestamp field
124                    };
125
126                    // Check if expired
127                    let age = now.signed_duration_since(expiration_time);
128                    if age.num_seconds() > rule.expiration_duration as i64 {
129                        // Apply action
130                        match rule.action {
131                            AgingAction::Delete => {
132                                // Get primary key value
133                                let pk_field = entity
134                                    .schema
135                                    .primary_key
136                                    .first()
137                                    .map(|s| s.as_str())
138                                    .unwrap_or("id");
139                                if let Some(pk_value) = record.get(pk_field) {
140                                    let delete_query = format!(
141                                        "DELETE FROM {} WHERE {} = ?",
142                                        table_name, pk_field
143                                    );
144                                    database
145                                        .execute(&delete_query, std::slice::from_ref(pk_value))
146                                        .await?;
147                                    total_cleaned += 1;
148                                }
149                            }
150                            AgingAction::MarkExpired => {
151                                // Update status field
152                                let pk_field = entity
153                                    .schema
154                                    .primary_key
155                                    .first()
156                                    .map(|s| s.as_str())
157                                    .unwrap_or("id");
158                                if let Some(pk_value) = record.get(pk_field) {
159                                    let update_query = format!(
160                                        "UPDATE {} SET status = ? WHERE {} = ?",
161                                        table_name, pk_field
162                                    );
163                                    database
164                                        .execute(
165                                            &update_query,
166                                            &[
167                                                serde_json::Value::String("expired".to_string()),
168                                                pk_value.clone(),
169                                            ],
170                                        )
171                                        .await?;
172                                    total_cleaned += 1;
173                                }
174                            }
175                            AgingAction::Archive => {
176                                // For now, just mark as archived (full archive would require archive table)
177                                let pk_field = entity
178                                    .schema
179                                    .primary_key
180                                    .first()
181                                    .map(|s| s.as_str())
182                                    .unwrap_or("id");
183                                if let Some(pk_value) = record.get(pk_field) {
184                                    let update_query = format!(
185                                        "UPDATE {} SET archived = ? WHERE {} = ?",
186                                        table_name, pk_field
187                                    );
188                                    database
189                                        .execute(
190                                            &update_query,
191                                            &[serde_json::Value::Bool(true), pk_value.clone()],
192                                        )
193                                        .await?;
194                                    total_cleaned += 1;
195                                }
196                            }
197                        }
198                    }
199                }
200            }
201        }
202
203        Ok(total_cleaned)
204    }
205
206    /// Update timestamp fields
207    ///
208    /// Automatically updates `updated_at` fields when `auto_update_timestamps` is enabled.
209    pub async fn update_timestamps(
210        &self,
211        database: &dyn crate::database::VirtualDatabase,
212        table: &str,
213        primary_key_field: &str,
214        primary_key_value: &serde_json::Value,
215    ) -> Result<()> {
216        // Update updated_at field if it exists
217        let now = self.now().to_rfc3339();
218        let update_query =
219            format!("UPDATE {} SET updated_at = ? WHERE {} = ?", table, primary_key_field);
220
221        // Try to update, but ignore if column doesn't exist
222        let _ = database
223            .execute(&update_query, &[serde_json::Value::String(now), primary_key_value.clone()])
224            .await;
225
226        Ok(())
227    }
228}
229
230impl Default for AgingManager {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use mockforge_core::VirtualClock;
240    use std::sync::Arc;
241
242    #[test]
243    fn test_aging_with_virtual_clock() {
244        // Create aging manager with virtual clock
245        let clock = Arc::new(VirtualClock::new());
246        let initial_time = chrono::Utc::now();
247        clock.enable_and_set(initial_time);
248
249        let aging_manager = AgingManager::with_virtual_clock(clock.clone());
250
251        // Verify that aging manager uses virtual clock
252        let now = aging_manager.now();
253        assert!((now - initial_time).num_seconds().abs() < 1);
254
255        // Advance virtual clock by 2 hours
256        clock.advance(chrono::Duration::hours(2));
257
258        // Verify aging manager now sees the advanced time
259        let advanced_now = aging_manager.now();
260        let elapsed = advanced_now - initial_time;
261        assert!(elapsed.num_hours() >= 1 && elapsed.num_hours() <= 3);
262    }
263
264    #[test]
265    fn test_aging_timestamps_with_virtual_clock() {
266        let clock = Arc::new(VirtualClock::new());
267        let initial_time = chrono::Utc::now();
268        clock.enable_and_set(initial_time);
269
270        let aging_manager = AgingManager::with_virtual_clock(clock.clone());
271
272        // Advance time by 1 month
273        clock.advance(chrono::Duration::days(30));
274
275        // Update timestamps should use virtual clock
276        // This is tested indirectly through the now() method
277        let now = aging_manager.now();
278        let elapsed = now - initial_time;
279        assert!(elapsed.num_days() >= 29 && elapsed.num_days() <= 31);
280    }
281
282    #[test]
283    fn test_one_month_aging_scenario() {
284        // Simulate "1 month later" scenario with data aging
285        let clock = Arc::new(VirtualClock::new());
286        let initial_time = chrono::Utc::now();
287        clock.enable_and_set(initial_time);
288
289        let aging_manager = AgingManager::with_virtual_clock(clock.clone());
290
291        // Initial time check
292        let start_time = aging_manager.now();
293        assert!((start_time - initial_time).num_seconds().abs() < 1);
294
295        // Advance by 1 month (30 days)
296        clock.advance(chrono::Duration::days(30));
297
298        // Verify aging manager sees the advanced time
299        let month_later = aging_manager.now();
300        let elapsed = month_later - start_time;
301
302        // Should be approximately 30 days
303        assert!(
304            elapsed.num_days() >= 29 && elapsed.num_days() <= 31,
305            "Expected ~30 days, got {} days",
306            elapsed.num_days()
307        );
308    }
309}