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.execute(&delete_query, &[pk_value.clone()]).await?;
145                                    total_cleaned += 1;
146                                }
147                            }
148                            AgingAction::MarkExpired => {
149                                // Update status field
150                                let pk_field = entity
151                                    .schema
152                                    .primary_key
153                                    .first()
154                                    .map(|s| s.as_str())
155                                    .unwrap_or("id");
156                                if let Some(pk_value) = record.get(pk_field) {
157                                    let update_query = format!(
158                                        "UPDATE {} SET status = ? WHERE {} = ?",
159                                        table_name, pk_field
160                                    );
161                                    database
162                                        .execute(
163                                            &update_query,
164                                            &[
165                                                serde_json::Value::String("expired".to_string()),
166                                                pk_value.clone(),
167                                            ],
168                                        )
169                                        .await?;
170                                    total_cleaned += 1;
171                                }
172                            }
173                            AgingAction::Archive => {
174                                // For now, just mark as archived (full archive would require archive table)
175                                let pk_field = entity
176                                    .schema
177                                    .primary_key
178                                    .first()
179                                    .map(|s| s.as_str())
180                                    .unwrap_or("id");
181                                if let Some(pk_value) = record.get(pk_field) {
182                                    let update_query = format!(
183                                        "UPDATE {} SET archived = ? WHERE {} = ?",
184                                        table_name, pk_field
185                                    );
186                                    database
187                                        .execute(
188                                            &update_query,
189                                            &[serde_json::Value::Bool(true), pk_value.clone()],
190                                        )
191                                        .await?;
192                                    total_cleaned += 1;
193                                }
194                            }
195                        }
196                    }
197                }
198            }
199        }
200
201        Ok(total_cleaned)
202    }
203
204    /// Update timestamp fields
205    ///
206    /// Automatically updates `updated_at` fields when `auto_update_timestamps` is enabled.
207    pub async fn update_timestamps(
208        &self,
209        database: &dyn crate::database::VirtualDatabase,
210        table: &str,
211        primary_key_field: &str,
212        primary_key_value: &serde_json::Value,
213    ) -> Result<()> {
214        // Update updated_at field if it exists
215        let now = self.now().to_rfc3339();
216        let update_query =
217            format!("UPDATE {} SET updated_at = ? WHERE {} = ?", table, primary_key_field);
218
219        // Try to update, but ignore if column doesn't exist
220        let _ = database
221            .execute(&update_query, &[serde_json::Value::String(now), primary_key_value.clone()])
222            .await;
223
224        Ok(())
225    }
226}
227
228impl Default for AgingManager {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use mockforge_core::VirtualClock;
238    use std::sync::Arc;
239
240    #[test]
241    fn test_aging_with_virtual_clock() {
242        // Create aging manager with virtual clock
243        let clock = Arc::new(VirtualClock::new());
244        let initial_time = chrono::Utc::now();
245        clock.enable_and_set(initial_time);
246
247        let aging_manager = AgingManager::with_virtual_clock(clock.clone());
248
249        // Verify that aging manager uses virtual clock
250        let now = aging_manager.now();
251        assert!((now - initial_time).num_seconds().abs() < 1);
252
253        // Advance virtual clock by 2 hours
254        clock.advance(chrono::Duration::hours(2));
255
256        // Verify aging manager now sees the advanced time
257        let advanced_now = aging_manager.now();
258        let elapsed = advanced_now - initial_time;
259        assert!(elapsed.num_hours() >= 1 && elapsed.num_hours() <= 3);
260    }
261
262    #[test]
263    fn test_aging_timestamps_with_virtual_clock() {
264        let clock = Arc::new(VirtualClock::new());
265        let initial_time = chrono::Utc::now();
266        clock.enable_and_set(initial_time);
267
268        let aging_manager = AgingManager::with_virtual_clock(clock.clone());
269
270        // Advance time by 1 month
271        clock.advance(chrono::Duration::days(30));
272
273        // Update timestamps should use virtual clock
274        // This is tested indirectly through the now() method
275        let now = aging_manager.now();
276        let elapsed = now - initial_time;
277        assert!(elapsed.num_days() >= 29 && elapsed.num_days() <= 31);
278    }
279
280    #[test]
281    fn test_one_month_aging_scenario() {
282        // Simulate "1 month later" scenario with data aging
283        let clock = Arc::new(VirtualClock::new());
284        let initial_time = chrono::Utc::now();
285        clock.enable_and_set(initial_time);
286
287        let aging_manager = AgingManager::with_virtual_clock(clock.clone());
288
289        // Initial time check
290        let start_time = aging_manager.now();
291        assert!((start_time - initial_time).num_seconds().abs() < 1);
292
293        // Advance by 1 month (30 days)
294        clock.advance(chrono::Duration::days(30));
295
296        // Verify aging manager sees the advanced time
297        let month_later = aging_manager.now();
298        let elapsed = month_later - start_time;
299
300        // Should be approximately 30 days
301        assert!(
302            elapsed.num_days() >= 29 && elapsed.num_days() <= 31,
303            "Expected ~30 days, got {} days",
304            elapsed.num_days()
305        );
306    }
307}