Skip to main content

pylon_plugin/builtin/
versioning.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::Plugin;
5use pylon_auth::AuthContext;
6use serde_json::Value;
7
8/// A versioned snapshot of a row.
9#[derive(Debug, Clone)]
10pub struct RowVersion {
11    pub entity: String,
12    pub row_id: String,
13    pub version: u64,
14    pub data: Value,
15    pub changed_by: Option<String>,
16    pub changed_at: String,
17}
18
19/// Versioning plugin. Keeps a history of all row changes for undo/audit.
20pub struct VersioningPlugin {
21    /// Map of "entity:row_id" -> list of versions.
22    history: Mutex<HashMap<String, Vec<RowVersion>>>,
23    /// Max versions to keep per row. 0 = unlimited.
24    max_versions: usize,
25}
26
27impl VersioningPlugin {
28    pub fn new(max_versions: usize) -> Self {
29        Self {
30            history: Mutex::new(HashMap::new()),
31            max_versions,
32        }
33    }
34
35    /// Get version history for a row.
36    pub fn get_history(&self, entity: &str, row_id: &str) -> Vec<RowVersion> {
37        let key = format!("{entity}:{row_id}");
38        self.history
39            .lock()
40            .unwrap()
41            .get(&key)
42            .cloned()
43            .unwrap_or_default()
44    }
45
46    /// Get a specific version of a row.
47    pub fn get_version(&self, entity: &str, row_id: &str, version: u64) -> Option<RowVersion> {
48        self.get_history(entity, row_id)
49            .into_iter()
50            .find(|v| v.version == version)
51    }
52
53    /// Get the latest version number for a row.
54    pub fn latest_version(&self, entity: &str, row_id: &str) -> u64 {
55        self.get_history(entity, row_id)
56            .last()
57            .map(|v| v.version)
58            .unwrap_or(0)
59    }
60
61    fn record(&self, entity: &str, row_id: &str, data: &Value, auth: &AuthContext) {
62        let key = format!("{entity}:{row_id}");
63        let mut history = self.history.lock().unwrap();
64        let versions = history.entry(key).or_default();
65
66        let version = versions.last().map(|v| v.version + 1).unwrap_or(1);
67        versions.push(RowVersion {
68            entity: entity.to_string(),
69            row_id: row_id.to_string(),
70            version,
71            data: data.clone(),
72            changed_by: auth.user_id.clone(),
73            changed_at: now(),
74        });
75
76        // Trim if over max.
77        if self.max_versions > 0 && versions.len() > self.max_versions {
78            let excess = versions.len() - self.max_versions;
79            versions.drain(0..excess);
80        }
81    }
82}
83
84impl Plugin for VersioningPlugin {
85    fn name(&self) -> &str {
86        "versioning"
87    }
88
89    fn after_insert(&self, entity: &str, id: &str, data: &Value, auth: &AuthContext) {
90        self.record(entity, id, data, auth);
91    }
92
93    fn after_update(&self, entity: &str, id: &str, data: &Value, auth: &AuthContext) {
94        self.record(entity, id, data, auth);
95    }
96}
97
98fn now() -> String {
99    use std::time::{SystemTime, UNIX_EPOCH};
100    format!(
101        "{}Z",
102        SystemTime::now()
103            .duration_since(UNIX_EPOCH)
104            .unwrap_or_default()
105            .as_secs()
106    )
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn records_insert() {
115        let plugin = VersioningPlugin::new(0);
116        let auth = AuthContext::authenticated("user-1".into());
117        plugin.after_insert("Todo", "t1", &serde_json::json!({"title": "V1"}), &auth);
118
119        let history = plugin.get_history("Todo", "t1");
120        assert_eq!(history.len(), 1);
121        assert_eq!(history[0].version, 1);
122        assert_eq!(history[0].changed_by, Some("user-1".into()));
123    }
124
125    #[test]
126    fn records_updates() {
127        let plugin = VersioningPlugin::new(0);
128        let auth = AuthContext::authenticated("user-1".into());
129        plugin.after_insert("Todo", "t1", &serde_json::json!({"title": "V1"}), &auth);
130        plugin.after_update("Todo", "t1", &serde_json::json!({"title": "V2"}), &auth);
131        plugin.after_update("Todo", "t1", &serde_json::json!({"title": "V3"}), &auth);
132
133        let history = plugin.get_history("Todo", "t1");
134        assert_eq!(history.len(), 3);
135        assert_eq!(history[0].version, 1);
136        assert_eq!(history[2].version, 3);
137    }
138
139    #[test]
140    fn get_specific_version() {
141        let plugin = VersioningPlugin::new(0);
142        let auth = AuthContext::anonymous();
143        plugin.after_insert("Todo", "t1", &serde_json::json!({"title": "V1"}), &auth);
144        plugin.after_update("Todo", "t1", &serde_json::json!({"title": "V2"}), &auth);
145
146        let v1 = plugin.get_version("Todo", "t1", 1).unwrap();
147        assert_eq!(v1.data["title"], "V1");
148
149        let v2 = plugin.get_version("Todo", "t1", 2).unwrap();
150        assert_eq!(v2.data["title"], "V2");
151
152        assert!(plugin.get_version("Todo", "t1", 99).is_none());
153    }
154
155    #[test]
156    fn latest_version() {
157        let plugin = VersioningPlugin::new(0);
158        let auth = AuthContext::anonymous();
159        assert_eq!(plugin.latest_version("Todo", "t1"), 0);
160
161        plugin.after_insert("Todo", "t1", &serde_json::json!({}), &auth);
162        assert_eq!(plugin.latest_version("Todo", "t1"), 1);
163
164        plugin.after_update("Todo", "t1", &serde_json::json!({}), &auth);
165        assert_eq!(plugin.latest_version("Todo", "t1"), 2);
166    }
167
168    #[test]
169    fn max_versions_trims() {
170        let plugin = VersioningPlugin::new(2);
171        let auth = AuthContext::anonymous();
172        plugin.after_insert("Todo", "t1", &serde_json::json!({"v": 1}), &auth);
173        plugin.after_update("Todo", "t1", &serde_json::json!({"v": 2}), &auth);
174        plugin.after_update("Todo", "t1", &serde_json::json!({"v": 3}), &auth);
175
176        let history = plugin.get_history("Todo", "t1");
177        assert_eq!(history.len(), 2);
178        assert_eq!(history[0].data["v"], 2); // V1 trimmed
179        assert_eq!(history[1].data["v"], 3);
180    }
181
182    #[test]
183    fn separate_rows_separate_history() {
184        let plugin = VersioningPlugin::new(0);
185        let auth = AuthContext::anonymous();
186        plugin.after_insert("Todo", "t1", &serde_json::json!({"title": "A"}), &auth);
187        plugin.after_insert("Todo", "t2", &serde_json::json!({"title": "B"}), &auth);
188
189        assert_eq!(plugin.get_history("Todo", "t1").len(), 1);
190        assert_eq!(plugin.get_history("Todo", "t2").len(), 1);
191    }
192}