1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::Plugin;
5use pylon_auth::AuthContext;
6use serde_json::Value;
7
8#[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
19pub struct VersioningPlugin {
21 history: Mutex<HashMap<String, Vec<RowVersion>>>,
23 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 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 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 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 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); 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}