1use linkrs_core::{StorageError, StorageResult};
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum MigrationStatus {
10 Applied,
11 RolledBack,
12 Failed,
13}
14
15impl std::fmt::Display for MigrationStatus {
16 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 match self {
18 MigrationStatus::Applied => write!(f, "applied"),
19 MigrationStatus::RolledBack => write!(f, "rolled_back"),
20 MigrationStatus::Failed => write!(f, "failed"),
21 }
22 }
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct MigrationHistoryRecord {
28 pub id: u64,
29 pub space: String,
30 pub label: String,
31 pub is_edge: bool,
32 pub from_version: u64,
33 pub to_version: u64,
34 pub plan_hash: String,
35 pub safety_level: String,
36 pub steps_count: usize,
37 pub rows_migrated: u64,
38 pub status: MigrationStatus,
39 pub applied_at: u64,
40 pub completed_at: Option<u64>,
41 pub error_message: Option<String>,
42}
43
44impl MigrationHistoryRecord {
45 #[allow(clippy::too_many_arguments)]
46 pub fn new(
47 space: String,
48 label: String,
49 is_edge: bool,
50 from_version: u64,
51 to_version: u64,
52 plan_hash: String,
53 safety_level: String,
54 steps_count: usize,
55 rows_migrated: u64,
56 status: MigrationStatus,
57 ) -> Self {
58 let now = std::time::SystemTime::now()
59 .duration_since(std::time::UNIX_EPOCH)
60 .map(|d| d.as_millis() as u64)
61 .unwrap_or(0);
62 Self {
63 id: 0,
64 space,
65 label,
66 is_edge,
67 from_version,
68 to_version,
69 plan_hash,
70 safety_level,
71 steps_count,
72 rows_migrated,
73 status,
74 applied_at: now,
75 completed_at: Some(now),
76 error_message: None,
77 }
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
83pub struct MigrationHistoryManager {
84 records: Vec<MigrationHistoryRecord>,
85 next_id: u64,
86 #[serde(skip)]
87 index: HashMap<(String, String, bool, u64), usize>,
88}
89
90impl MigrationHistoryManager {
91 pub fn new() -> Self {
92 Self {
93 records: Vec::new(),
94 next_id: 1,
95 index: HashMap::new(),
96 }
97 }
98
99 fn rebuild_index(&mut self) {
100 self.index.clear();
101 for (idx, r) in self.records.iter().enumerate() {
102 self.index.insert(
103 (r.space.clone(), r.label.clone(), r.is_edge, r.to_version),
104 idx,
105 );
106 }
107 if let Some(max_id) = self.records.iter().map(|r| r.id).max() {
108 self.next_id = max_id + 1;
109 } else {
110 self.next_id = 1;
111 }
112 }
113
114 pub fn record(&mut self, mut rec: MigrationHistoryRecord) -> StorageResult<()> {
115 let key = (
116 rec.space.clone(),
117 rec.label.clone(),
118 rec.is_edge,
119 rec.to_version,
120 );
121 if self.index.contains_key(&key) {
122 return Err(StorageError::already_exists(format!(
123 "migration_history unique violation: space={} label={} is_edge={} to_version={}",
124 rec.space, rec.label, rec.is_edge, rec.to_version
125 )));
126 }
127 rec.id = self.next_id;
128 self.next_id += 1;
129 let idx = self.records.len();
130 self.records.push(rec);
131 self.index.insert(key, idx);
132 Ok(())
133 }
134
135 pub fn list(&self, space: &str, label: &str, is_edge: bool) -> Vec<MigrationHistoryRecord> {
136 self.records
137 .iter()
138 .filter(|r| r.space == space && r.label == label && r.is_edge == is_edge)
139 .cloned()
140 .collect()
141 }
142
143 pub fn list_all(&self) -> Vec<MigrationHistoryRecord> {
144 self.records.clone()
145 }
146
147 pub fn get_applied_versions(&self, space: &str, label: &str, is_edge: bool) -> HashSet<u64> {
148 self.records
149 .iter()
150 .filter(|r| {
151 r.space == space
152 && r.label == label
153 && r.is_edge == is_edge
154 && r.status == MigrationStatus::Applied
155 })
156 .map(|r| r.to_version)
157 .collect()
158 }
159
160 pub fn get_applied_versions_sorted(&self, space: &str, label: &str, is_edge: bool) -> Vec<u64> {
161 let mut v: Vec<u64> = self
162 .get_applied_versions(space, label, is_edge)
163 .into_iter()
164 .collect();
165 v.sort_unstable();
166 v
167 }
168
169 pub fn check_chain(
176 applied_sorted: &[u64],
177 space: &str,
178 label: &str,
179 is_edge: bool,
180 from_version: u64,
181 to_version: u64,
182 ) -> StorageResult<()> {
183 if to_version <= from_version {
184 return Err(StorageError::invalid_input(format!(
185 "non-forward migration range v{from_version} -> v{to_version} for {space}/{label}"
186 )));
187 }
188 if applied_sorted.contains(&to_version) {
189 return Err(StorageError::already_exists(format!(
190 "migration target v{to_version} for {space}/{label} is already applied"
191 )));
192 }
193 let mut ascending = applied_sorted.to_vec();
194 ascending.sort_unstable();
195 if let Some(&latest) = ascending.last() {
196 if from_version != latest {
197 return Err(StorageError::invalid_input(format!(
198 "migration chain gap for {space}/{label} (is_edge={is_edge}): latest applied is v{latest}, plan starts at v{from_version}"
199 )));
200 }
201 }
202 Ok(())
203 }
204
205 pub fn validate_version_chain(
207 &self,
208 space: &str,
209 label: &str,
210 is_edge: bool,
211 from_version: u64,
212 to_version: u64,
213 ) -> StorageResult<()> {
214 Self::check_chain(
215 &self.get_applied_versions_sorted(space, label, is_edge),
216 space,
217 label,
218 is_edge,
219 from_version,
220 to_version,
221 )
222 }
223
224 pub fn find(
225 &self,
226 space: &str,
227 label: &str,
228 is_edge: bool,
229 to_version: u64,
230 ) -> Option<MigrationHistoryRecord> {
231 let key = (space.to_string(), label.to_string(), is_edge, to_version);
232 self.index
233 .get(&key)
234 .and_then(|&idx| self.records.get(idx).cloned())
235 }
236
237 pub fn save_to_file(&self, path: &Path) -> StorageResult<()> {
238 if let Some(parent) = path.parent() {
239 std::fs::create_dir_all(parent).map_err(|e| StorageError::io_error(e.to_string()))?;
240 }
241 let json = serde_json::to_string_pretty(&self.records)
242 .map_err(|e| StorageError::serialize_error(e.to_string()))?;
243 std::fs::write(path, json).map_err(|e| StorageError::io_error(e.to_string()))?;
244 Ok(())
245 }
246
247 pub fn load_from_file(path: &Path) -> StorageResult<Self> {
248 if !path.exists() {
249 return Ok(Self::new());
250 }
251 let data =
252 std::fs::read_to_string(path).map_err(|e| StorageError::io_error(e.to_string()))?;
253 if data.trim().is_empty() {
254 return Ok(Self::new());
255 }
256 let records: Vec<MigrationHistoryRecord> = serde_json::from_str(&data)
257 .map_err(|e| StorageError::deserialize_error(e.to_string()))?;
258 let mut mgr = Self {
259 records,
260 next_id: 1,
261 index: HashMap::new(),
262 };
263 mgr.rebuild_index();
264 Ok(mgr)
265 }
266
267 pub fn clear(&mut self) {
268 self.records.clear();
269 self.index.clear();
270 self.next_id = 1;
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn test_record_and_query() {
280 let mut mgr = MigrationHistoryManager::new();
281 let rec = MigrationHistoryRecord::new(
282 "s".into(),
283 "l".into(),
284 false,
285 1,
286 2,
287 "hash".into(),
288 "SAFE".into(),
289 1,
290 10,
291 MigrationStatus::Applied,
292 );
293 mgr.record(rec).unwrap();
294 assert_eq!(mgr.get_applied_versions("s", "l", false).len(), 1);
295 assert!(mgr.get_applied_versions("s", "l", true).is_empty());
296 }
297
298 #[test]
299 fn test_unique_violation() {
300 let mut mgr = MigrationHistoryManager::new();
301 let rec = MigrationHistoryRecord::new(
302 "s".into(),
303 "l".into(),
304 false,
305 1,
306 2,
307 "hash".into(),
308 "SAFE".into(),
309 1,
310 10,
311 MigrationStatus::Applied,
312 );
313 mgr.record(rec.clone()).unwrap();
314 let err = mgr.record(rec).unwrap_err();
315 assert_eq!(
316 err.kind(),
317 linkrs_core::error::storage::StorageErrorKind::AlreadyExists
318 );
319 }
320
321 #[test]
322 fn test_save_load() {
323 let mut mgr = MigrationHistoryManager::new();
324 mgr.record(MigrationHistoryRecord::new(
325 "s".into(),
326 "l".into(),
327 false,
328 1,
329 2,
330 "h".into(),
331 "SAFE".into(),
332 1,
333 5,
334 MigrationStatus::Applied,
335 ))
336 .unwrap();
337 let tmp = tempfile::NamedTempFile::new().unwrap();
338 mgr.save_to_file(tmp.path()).unwrap();
339 let loaded = MigrationHistoryManager::load_from_file(tmp.path()).unwrap();
340 assert_eq!(loaded.records.len(), 1);
341 assert_eq!(loaded.get_applied_versions("s", "l", false).len(), 1);
342 }
343
344 #[test]
345 fn test_check_chain_fresh_label() {
346 assert!(MigrationHistoryManager::check_chain(&[], "s", "l", false, 1, 2).is_ok());
347 }
348
349 #[test]
350 fn test_check_chain_rejects_non_forward() {
351 assert!(MigrationHistoryManager::check_chain(&[], "s", "l", false, 2, 2).is_err());
352 assert!(MigrationHistoryManager::check_chain(&[], "s", "l", false, 3, 2).is_err());
353 }
354
355 #[test]
356 fn test_check_chain_rejects_reapply() {
357 assert!(MigrationHistoryManager::check_chain(&[2], "s", "l", false, 1, 2).is_err());
358 }
359
360 #[test]
361 fn test_check_chain_rejects_gap() {
362 assert!(MigrationHistoryManager::check_chain(&[2], "s", "l", false, 3, 4).is_err());
363 assert!(MigrationHistoryManager::check_chain(&[1, 2], "s", "l", false, 1, 3).is_err());
364 }
365
366 #[test]
367 fn test_check_chain_accepts_continuation() {
368 assert!(MigrationHistoryManager::check_chain(&[2], "s", "l", false, 2, 3).is_ok());
369 assert!(MigrationHistoryManager::check_chain(&[1, 2], "s", "l", false, 2, 5).is_ok());
370 }
371
372 #[test]
373 fn test_validate_version_chain_instance() {
374 let mut mgr = MigrationHistoryManager::new();
375 mgr.record(MigrationHistoryRecord::new(
376 "s".into(),
377 "l".into(),
378 false,
379 1,
380 2,
381 "h".into(),
382 "SAFE".into(),
383 1,
384 5,
385 MigrationStatus::Applied,
386 ))
387 .unwrap();
388 assert!(mgr.validate_version_chain("s", "l", false, 2, 3).is_ok());
389 assert!(mgr.validate_version_chain("s", "l", false, 1, 3).is_err());
390 }
391}