Skip to main content

linkrs_migration/
plan.rs

1use linkrs_core::{DataType, Value};
2use serde::{Deserialize, Serialize};
3
4use crate::error::MigrationError;
5
6#[cfg(test)]
7mod tests {
8    use super::*;
9
10    #[test]
11    fn test_safety_level_ordering() {
12        assert!(SafetyLevel::Safe.is_safe());
13        assert!(!SafetyLevel::Warning.is_safe());
14        assert!(!SafetyLevel::Dangerous.is_safe());
15        assert_eq!(SafetyLevel::Safe.label(), "SAFE");
16        assert_eq!(SafetyLevel::Warning.label(), "WARNING");
17        assert_eq!(SafetyLevel::Dangerous.label(), "DANGEROUS");
18    }
19
20    #[test]
21    fn test_add_column_safety() {
22        let step = MigrationStep::AddColumn {
23            name: "email".into(),
24            data_type: DataType::String,
25            nullable: true,
26            default_value: None,
27        };
28        assert_eq!(step.safety_level(), SafetyLevel::Safe);
29        assert!(!step.is_data_modifying());
30    }
31
32    #[test]
33    fn test_drop_column_safety() {
34        let step = MigrationStep::DropColumn {
35            name: "email".into(),
36        };
37        assert_eq!(step.safety_level(), SafetyLevel::Dangerous);
38        assert!(step.is_data_modifying());
39    }
40
41    #[test]
42    fn test_rename_column_safety() {
43        let step = MigrationStep::RenameColumn {
44            old_name: "old".into(),
45            new_name: "new".into(),
46        };
47        assert_eq!(step.safety_level(), SafetyLevel::Warning);
48        assert!(step.is_data_modifying());
49    }
50
51    #[test]
52    fn test_convert_type_safety() {
53        let step = MigrationStep::ConvertType {
54            name: "age".into(),
55            from_type: DataType::Int,
56            to_type: DataType::BigInt,
57        };
58        assert_eq!(step.safety_level(), SafetyLevel::Warning);
59        assert!(step.is_data_modifying());
60    }
61
62    #[test]
63    fn test_step_reverse() {
64        let add = MigrationStep::AddColumn {
65            name: "x".into(),
66            data_type: DataType::String,
67            nullable: true,
68            default_value: None,
69        };
70        assert_eq!(
71            add.reverse(),
72            Some(MigrationStep::DropColumn { name: "x".into() })
73        );
74
75        assert_eq!(
76            MigrationStep::DropColumn { name: "x".into() }.reverse(),
77            None
78        );
79
80        let rename = MigrationStep::RenameColumn {
81            old_name: "a".into(),
82            new_name: "b".into(),
83        };
84        assert_eq!(
85            rename.reverse(),
86            Some(MigrationStep::RenameColumn {
87                old_name: "b".into(),
88                new_name: "a".into(),
89            })
90        );
91
92        let convert = MigrationStep::ConvertType {
93            name: "c".into(),
94            from_type: DataType::Int,
95            to_type: DataType::BigInt,
96        };
97        assert_eq!(
98            convert.reverse(),
99            Some(MigrationStep::ConvertType {
100                name: "c".into(),
101                from_type: DataType::BigInt,
102                to_type: DataType::Int,
103            })
104        );
105    }
106
107    #[test]
108    fn test_step_description() {
109        let step = MigrationStep::AddColumn {
110            name: "email".into(),
111            data_type: DataType::String,
112            nullable: false,
113            default_value: None,
114        };
115        assert!(step.description().contains("email"));
116        assert!(step.description().contains("String"));
117
118        let drop = MigrationStep::DropColumn { name: "x".into() };
119        assert!(drop.description().contains("x"));
120        assert!(drop.description().contains("lost"));
121    }
122
123    #[test]
124    fn test_empty_migration_plan() {
125        let plan = MigrationPlan::new(
126            MigrationTarget {
127                space: "test".into(),
128                label: "User".into(),
129                is_edge: false,
130            },
131            VersionRange { from: 1, to: 2 },
132            vec![],
133            0,
134            SafetyLevel::Safe,
135            None,
136        );
137        assert!(plan.is_empty());
138        assert!(plan.print_summary().contains("SAFE"));
139    }
140
141    #[test]
142    fn test_migration_report() {
143        let report = MigrationReport {
144            success: true,
145            steps_completed: 3,
146            rows_migrated: 100,
147            errors: vec![],
148            completed_step_indices: vec![],
149        };
150        assert!(report.print_summary().contains("SUCCESS"));
151        assert!(report.print_summary().contains("100"));
152
153        let failed = MigrationReport {
154            success: false,
155            steps_completed: 1,
156            rows_migrated: 0,
157            errors: vec!["Error converting value".into()],
158            completed_step_indices: vec![],
159        };
160        assert!(failed.print_summary().contains("FAILED"));
161        assert!(failed.print_summary().contains("Error converting value"));
162    }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166pub enum SafetyLevel {
167    Safe,
168    Warning,
169    Dangerous,
170}
171
172impl SafetyLevel {
173    pub fn is_safe(&self) -> bool {
174        matches!(self, SafetyLevel::Safe)
175    }
176
177    pub fn label(&self) -> &'static str {
178        match self {
179            SafetyLevel::Safe => "SAFE",
180            SafetyLevel::Warning => "WARNING",
181            SafetyLevel::Dangerous => "DANGEROUS",
182        }
183    }
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub enum MigrationStep {
188    AddColumn {
189        name: String,
190        data_type: DataType,
191        nullable: bool,
192        default_value: Option<Value>,
193    },
194    DropColumn {
195        name: String,
196    },
197    RenameColumn {
198        old_name: String,
199        new_name: String,
200    },
201    ConvertType {
202        name: String,
203        from_type: DataType,
204        to_type: DataType,
205    },
206    SetDefault {
207        name: String,
208        default_value: Option<Value>,
209    },
210    ChangeNullability {
211        name: String,
212        was_nullable: bool,
213        now_nullable: bool,
214    },
215    CreateLabel {
216        label_name: String,
217    },
218    DropLabel {
219        label_name: String,
220    },
221    CreateEdgeType {
222        edge_type_name: String,
223    },
224    DropEdgeType {
225        edge_type_name: String,
226    },
227}
228
229impl MigrationStep {
230    pub fn safety_level(&self) -> SafetyLevel {
231        match self {
232            MigrationStep::AddColumn { .. } => SafetyLevel::Safe,
233            MigrationStep::DropColumn { .. } => SafetyLevel::Dangerous,
234            MigrationStep::RenameColumn { .. } => SafetyLevel::Warning,
235            MigrationStep::ConvertType { .. } => SafetyLevel::Warning,
236            MigrationStep::SetDefault { .. } => SafetyLevel::Safe,
237            MigrationStep::ChangeNullability { .. } => SafetyLevel::Warning,
238            MigrationStep::CreateLabel { .. } => SafetyLevel::Safe,
239            MigrationStep::DropLabel { .. } => SafetyLevel::Dangerous,
240            MigrationStep::CreateEdgeType { .. } => SafetyLevel::Safe,
241            MigrationStep::DropEdgeType { .. } => SafetyLevel::Dangerous,
242        }
243    }
244
245    pub fn is_data_modifying(&self) -> bool {
246        matches!(
247            self,
248            MigrationStep::DropColumn { .. }
249                | MigrationStep::RenameColumn { .. }
250                | MigrationStep::ConvertType { .. }
251                | MigrationStep::SetDefault { .. }
252        )
253    }
254
255    pub fn is_schema_modifying(&self) -> bool {
256        matches!(
257            self,
258            MigrationStep::CreateLabel { .. }
259                | MigrationStep::DropLabel { .. }
260                | MigrationStep::CreateEdgeType { .. }
261                | MigrationStep::DropEdgeType { .. }
262        )
263    }
264
265    pub fn description(&self) -> String {
266        match self {
267            MigrationStep::AddColumn {
268                name,
269                data_type: dt,
270                nullable,
271                ..
272            } => {
273                format!(
274                    "Add column '{}' of type {:?} (nullable: {})",
275                    name, dt, nullable
276                )
277            }
278            MigrationStep::DropColumn { name } => {
279                format!("Drop column '{}' - existing data will be lost", name)
280            }
281            MigrationStep::RenameColumn { old_name, new_name } => {
282                format!("Rename column '{}' to '{}'", old_name, new_name)
283            }
284            MigrationStep::ConvertType {
285                name,
286                from_type,
287                to_type,
288            } => {
289                format!(
290                    "Convert column '{}' from {:?} to {:?}",
291                    name, from_type, to_type
292                )
293            }
294            MigrationStep::SetDefault {
295                name,
296                default_value,
297            } => {
298                format!(
299                    "Set default value for column '{}' to {:?}",
300                    name, default_value
301                )
302            }
303            MigrationStep::ChangeNullability {
304                name,
305                was_nullable,
306                now_nullable,
307            } => {
308                format!(
309                    "Change column '{}' nullability from {} to {}",
310                    name, was_nullable, now_nullable
311                )
312            }
313            MigrationStep::CreateLabel { label_name } => {
314                format!("Create label '{}'", label_name)
315            }
316            MigrationStep::DropLabel { label_name } => {
317                format!("Drop label '{}' - all data will be lost", label_name)
318            }
319            MigrationStep::CreateEdgeType { edge_type_name } => {
320                format!("Create edge type '{}'", edge_type_name)
321            }
322            MigrationStep::DropEdgeType { edge_type_name } => {
323                format!(
324                    "Drop edge type '{}' - all data will be lost",
325                    edge_type_name
326                )
327            }
328        }
329    }
330
331    pub fn reverse(&self) -> Option<MigrationStep> {
332        match self {
333            MigrationStep::AddColumn { name, .. } => {
334                Some(MigrationStep::DropColumn { name: name.clone() })
335            }
336            MigrationStep::DropColumn { name: _ } => None,
337            MigrationStep::RenameColumn { old_name, new_name } => {
338                Some(MigrationStep::RenameColumn {
339                    old_name: new_name.clone(),
340                    new_name: old_name.clone(),
341                })
342            }
343            MigrationStep::ConvertType {
344                name,
345                from_type,
346                to_type,
347            } => Some(MigrationStep::ConvertType {
348                name: name.clone(),
349                from_type: to_type.clone(),
350                to_type: from_type.clone(),
351            }),
352            MigrationStep::SetDefault {
353                name,
354                default_value,
355            } => Some(MigrationStep::SetDefault {
356                name: name.clone(),
357                default_value: default_value.clone(),
358            }),
359            MigrationStep::ChangeNullability {
360                name,
361                was_nullable,
362                now_nullable,
363            } => Some(MigrationStep::ChangeNullability {
364                name: name.clone(),
365                was_nullable: *now_nullable,
366                now_nullable: *was_nullable,
367            }),
368            MigrationStep::CreateLabel { label_name } => Some(MigrationStep::DropLabel {
369                label_name: label_name.clone(),
370            }),
371            MigrationStep::DropLabel { .. } => None,
372            MigrationStep::CreateEdgeType { edge_type_name } => Some(MigrationStep::DropEdgeType {
373                edge_type_name: edge_type_name.clone(),
374            }),
375            MigrationStep::DropEdgeType { .. } => None,
376        }
377    }
378}
379
380const DEFAULT_BATCH_SIZE: usize = 1000;
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct MigrationTarget {
384    pub space: String,
385    pub label: String,
386    pub is_edge: bool,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct VersionRange {
391    pub from: u64,
392    pub to: u64,
393}
394
395fn compute_steps_hash(steps: &[MigrationStep]) -> String {
396    use sha2::{Digest, Sha256};
397    let json = serde_json::to_string(steps).unwrap_or_default();
398    let mut hasher = Sha256::new();
399    hasher.update(json.as_bytes());
400    let result = hasher.finalize();
401    result.iter().map(|b| format!("{b:02x}")).collect()
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct MigrationPlan {
406    pub target: MigrationTarget,
407    pub version_range: VersionRange,
408    pub steps: Vec<MigrationStep>,
409    pub estimated_rows: u64,
410    pub overall_safety: SafetyLevel,
411    pub rollback_plan: Option<Box<MigrationPlan>>,
412    pub batch_size: usize,
413    pub completed_steps: Vec<usize>,
414    #[serde(default)]
415    pub plan_hash: String,
416    #[serde(default)]
417    pub dry_run: bool,
418    #[serde(default)]
419    pub created_at: Option<u64>,
420    #[serde(default)]
421    pub expand_contract: Option<bool>,
422}
423
424impl MigrationPlan {
425    pub fn new(
426        target: MigrationTarget,
427        version_range: VersionRange,
428        steps: Vec<MigrationStep>,
429        estimated_rows: u64,
430        overall_safety: SafetyLevel,
431        rollback_plan: Option<Box<MigrationPlan>>,
432    ) -> Self {
433        let hash = compute_steps_hash(&steps);
434        let now = std::time::SystemTime::now()
435            .duration_since(std::time::UNIX_EPOCH)
436            .map(|d| d.as_millis() as u64)
437            .unwrap_or(0);
438        Self {
439            target,
440            version_range,
441            steps,
442            estimated_rows,
443            overall_safety,
444            rollback_plan,
445            batch_size: DEFAULT_BATCH_SIZE,
446            completed_steps: Vec::new(),
447            plan_hash: hash,
448            dry_run: false,
449            created_at: Some(now),
450            expand_contract: None,
451        }
452    }
453
454    pub fn new_with_config(
455        target: MigrationTarget,
456        version_range: VersionRange,
457        steps: Vec<MigrationStep>,
458        estimated_rows: u64,
459        overall_safety: SafetyLevel,
460        rollback_plan: Option<Box<MigrationPlan>>,
461        config: &crate::config::MigrationConfig,
462    ) -> Self {
463        let mut plan = Self::new(
464            target,
465            version_range,
466            steps,
467            estimated_rows,
468            overall_safety,
469            rollback_plan,
470        );
471        if config.batch_size != 0 {
472            plan.batch_size = config.batch_size;
473        }
474        plan
475    }
476
477    pub fn compute_hash(&self) -> String {
478        compute_steps_hash(&self.steps)
479    }
480
481    pub fn refresh_hash(&mut self) {
482        self.plan_hash = compute_steps_hash(&self.steps);
483    }
484
485    pub fn is_empty(&self) -> bool {
486        self.steps.is_empty()
487    }
488
489    pub fn has_schema_modifying_steps(&self) -> bool {
490        self.steps.iter().any(|s| s.is_schema_modifying())
491    }
492
493    /// True when the plan destroys data that no step can reconstruct.
494    /// Such plans need a pre-migration backup for rollback to be possible.
495    pub fn requires_backup(&self) -> bool {
496        self.steps.iter().any(|s| {
497            matches!(
498                s,
499                MigrationStep::DropColumn { .. }
500                    | MigrationStep::DropLabel { .. }
501                    | MigrationStep::DropEdgeType { .. }
502            )
503        })
504    }
505
506    /// Names dropped by `DropColumn` steps, for backup/restore merging.
507    pub fn dropped_columns(&self) -> Vec<String> {
508        self.steps
509            .iter()
510            .filter_map(|s| match s {
511                MigrationStep::DropColumn { name } => Some(name.clone()),
512                _ => None,
513            })
514            .collect()
515    }
516
517    pub fn remaining_steps(&self) -> Vec<usize> {
518        (0..self.steps.len())
519            .filter(|i| !self.completed_steps.contains(i))
520            .collect()
521    }
522
523    pub fn print_summary(&self) -> String {
524        let mut out = String::new();
525        out.push_str(&format!(
526            "Migration Plan: {} v{} → v{} ({} row(s))\n",
527            self.target.label, self.version_range.from, self.version_range.to, self.estimated_rows
528        ));
529        out.push_str(&format!(
530            "Safety: {} ({:?})\n",
531            self.overall_safety.label(),
532            self.overall_safety
533        ));
534        out.push_str(&format!("Steps: {}\n", self.steps.len()));
535        for (i, step) in self.steps.iter().enumerate() {
536            let prefix = if self.completed_steps.contains(&i) {
537                "[DONE] "
538            } else {
539                ""
540            };
541            out.push_str(&format!(
542                "  {}. {}[{:?}] {}\n",
543                i + 1,
544                prefix,
545                step.safety_level(),
546                step.description()
547            ));
548        }
549        if self.rollback_plan.is_some() {
550            out.push_str("Rollback plan: Available\n");
551        }
552        out
553    }
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct MigrationReport {
558    pub success: bool,
559    pub steps_completed: usize,
560    pub rows_migrated: u64,
561    pub errors: Vec<String>,
562    pub completed_step_indices: Vec<usize>,
563}
564
565impl MigrationReport {
566    pub fn print_summary(&self) -> String {
567        let status = if self.success { "SUCCESS" } else { "FAILED" };
568        let mut out = format!(
569            "Migration {}: {} step(s) completed, {} row(s) migrated, {} error(s)",
570            status,
571            self.steps_completed,
572            self.rows_migrated,
573            self.errors.len()
574        );
575        if !self.errors.is_empty() {
576            out.push_str("\nErrors:");
577            for (i, err) in self.errors.iter().enumerate() {
578                out.push_str(&format!("\n  {}. {}", i + 1, err));
579            }
580        }
581        out
582    }
583}
584
585pub fn checkpoint_now_millis() -> u64 {
586    std::time::SystemTime::now()
587        .duration_since(std::time::UNIX_EPOCH)
588        .unwrap()
589        .as_millis() as u64
590}
591
592/// Human-readable list of the plan's irreversible steps (drops), for
593/// logging and rollback errors. Returns None when every step is reversible.
594pub fn irreversible_guidance(plan: &MigrationPlan) -> Option<String> {
595    let mut items = Vec::new();
596    for step in &plan.steps {
597        match step {
598            MigrationStep::DropColumn { name } => items.push(format!(
599                "column '{}' on {}/{} is permanently removed; restore by re-adding the column and reloading data from a backup",
600                name, plan.target.space, plan.target.label
601            )),
602            MigrationStep::DropLabel { label_name } => items.push(format!(
603                "label '{}' on {} is permanently removed; restore by recreating the label and reloading all its vertices from a backup",
604                label_name, plan.target.space
605            )),
606            MigrationStep::DropEdgeType { edge_type_name } => items.push(format!(
607                "edge type '{}' on {} is permanently removed; restore by recreating the edge type and reloading all its edges from a backup",
608                edge_type_name, plan.target.space
609            )),
610            _ => {}
611        }
612    }
613    if items.is_empty() {
614        None
615    } else {
616        Some(format!(
617            "No automated rollback possible: {}",
618            items.join("; ")
619        ))
620    }
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
624pub enum StepResult {
625    Success,
626    Failed(String),
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct MigrationCheckpoint {
631    pub completed_step_index: usize,
632    pub rows_migrated_before: u64,
633    pub rows_migrated_after: u64,
634    pub timestamp: u64,
635    pub step_result: StepResult,
636    #[serde(default)]
637    pub completed_steps: Vec<usize>,
638}
639
640impl MigrationCheckpoint {
641    pub fn save(
642        &self,
643        plan: &MigrationPlan,
644        storage_path: &std::path::Path,
645    ) -> Result<(), MigrationError> {
646        let checkpoint_file = storage_path.join(checkpoint_file_name(plan));
647        let content = serde_json::to_string_pretty(self)
648            .map_err(|e| MigrationError::Checkpoint(format!("serialize checkpoint: {e}")))?;
649        std::fs::write(&checkpoint_file, content)
650            .map_err(|e| MigrationError::Checkpoint(format!("write checkpoint: {e}")))?;
651        Ok(())
652    }
653
654    pub fn load(
655        plan: &MigrationPlan,
656        storage_path: &std::path::Path,
657    ) -> Result<Option<Self>, MigrationError> {
658        let checkpoint_file = storage_path.join(checkpoint_file_name(plan));
659        if !checkpoint_file.exists() {
660            return Ok(None);
661        }
662        let content = std::fs::read_to_string(&checkpoint_file)
663            .map_err(|e| MigrationError::Checkpoint(format!("read checkpoint: {e}")))?;
664        let checkpoint: Self = serde_json::from_str(&content)
665            .map_err(|e| MigrationError::Checkpoint(format!("parse checkpoint: {e}")))?;
666        Ok(Some(checkpoint))
667    }
668
669    pub fn cleanup(
670        plan: &MigrationPlan,
671        storage_path: &std::path::Path,
672    ) -> Result<(), MigrationError> {
673        let checkpoint_file = storage_path.join(checkpoint_file_name(plan));
674        if checkpoint_file.exists() {
675            std::fs::remove_file(&checkpoint_file)
676                .map_err(|e| MigrationError::Checkpoint(format!("remove checkpoint: {e}")))?;
677        }
678        Ok(())
679    }
680}
681
682/// Escape a path component: only `[A-Za-z0-9_]` survive verbatim; every
683/// other byte becomes `%XX`, so `-` can separate fields unambiguously.
684pub(crate) fn push_escaped_path_component(out: &mut String, s: &str) {
685    for b in s.as_bytes() {
686        match b {
687            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' => out.push(*b as char),
688            _ => out.push_str(&format!("%{b:02X}")),
689        }
690    }
691}
692
693/// Checkpoint file name with space/label/hash escaped so the name stays a
694/// single path component. Only `[A-Za-z0-9_]` survive verbatim; every other
695/// byte becomes `%XX`, and `-` separates fields (never produced verbatim by
696/// the escaper, so field boundaries stay unambiguous).
697fn checkpoint_file_name(plan: &MigrationPlan) -> String {
698    let mut name = String::from("checkpoint_");
699    push_escaped_path_component(&mut name, &plan.target.space);
700    name.push('-');
701    push_escaped_path_component(&mut name, &plan.target.label);
702    name.push('-');
703    push_escaped_path_component(&mut name, &plan.plan_hash);
704    name.push_str(".json");
705    name
706}
707
708#[cfg(test)]
709mod checkpoint_tests {
710    use super::{checkpoint_now_millis, MigrationCheckpoint, MigrationTarget, StepResult};
711
712    fn make_plan(hash: &str) -> super::MigrationPlan {
713        let mut p = super::MigrationPlan::new(
714            MigrationTarget {
715                space: "s".into(),
716                label: "l".into(),
717                is_edge: false,
718            },
719            super::VersionRange { from: 1, to: 2 },
720            vec![],
721            0,
722            super::SafetyLevel::Safe,
723            None,
724        );
725        p.plan_hash = hash.to_string();
726        p
727    }
728
729    #[test]
730    fn test_checkpoint_save_load_cleanup() {
731        let tmp = tempfile::tempdir().unwrap();
732        let plan = make_plan("abc123");
733        let cp = MigrationCheckpoint {
734            completed_step_index: 1,
735            rows_migrated_before: 0,
736            rows_migrated_after: 10,
737            timestamp: checkpoint_now_millis(),
738            step_result: StepResult::Success,
739            completed_steps: vec![1],
740        };
741        cp.save(&plan, tmp.path()).unwrap();
742        let loaded = MigrationCheckpoint::load(&plan, tmp.path())
743            .unwrap()
744            .unwrap();
745        assert_eq!(loaded.completed_step_index, 1);
746        assert_eq!(loaded.rows_migrated_after, 10);
747        MigrationCheckpoint::cleanup(&plan, tmp.path()).unwrap();
748        assert!(MigrationCheckpoint::load(&plan, tmp.path())
749            .unwrap()
750            .is_none());
751    }
752
753    #[test]
754    fn test_checkpoint_missing_returns_none() {
755        let tmp = tempfile::tempdir().unwrap();
756        let plan = make_plan("hash2");
757        assert!(MigrationCheckpoint::load(&plan, tmp.path())
758            .unwrap()
759            .is_none());
760    }
761
762    #[test]
763    fn test_checkpoint_name_escapes_path_separators() {
764        let tmp = tempfile::tempdir().unwrap();
765        let mut p = super::MigrationPlan::new(
766            super::MigrationTarget {
767                space: "../evil".into(),
768                label: "a/b".into(),
769                is_edge: false,
770            },
771            super::VersionRange { from: 1, to: 2 },
772            vec![],
773            0,
774            super::SafetyLevel::Safe,
775            None,
776        );
777        p.plan_hash = "h1".to_string();
778        let cp = MigrationCheckpoint {
779            completed_step_index: 0,
780            rows_migrated_before: 0,
781            rows_migrated_after: 3,
782            timestamp: checkpoint_now_millis(),
783            step_result: StepResult::Success,
784            completed_steps: vec![0],
785        };
786        cp.save(&p, tmp.path()).unwrap();
787        let entries: Vec<_> = std::fs::read_dir(tmp.path())
788            .unwrap()
789            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
790            .collect();
791        assert_eq!(entries.len(), 1);
792        assert!(entries[0].starts_with("checkpoint_"));
793        assert!(!entries[0].contains('/'));
794        let loaded = MigrationCheckpoint::load(&p, tmp.path()).unwrap().unwrap();
795        assert_eq!(loaded.rows_migrated_after, 3);
796    }
797}