Skip to main content

synwire_storage/
migration.rs

1//! Storage schema migration support.
2//!
3//! Each subsystem that persists data on disk defines its schema version in a
4//! `version.json` file adjacent to its data directory.  The [`StorageMigration`]
5//! trait provides a standard interface for running incremental migrations.
6//!
7//! The migration strategy is **copy-then-swap**: a new directory is prepared
8//! alongside the old one, then renamed into place atomically.
9
10use crate::StorageError;
11use serde::{Deserialize, Serialize};
12use std::path::Path;
13
14/// Schema version metadata stored in `<dir>/version.json`.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct VersionFile {
17    /// Current schema version of the data in this directory.
18    pub version: u32,
19    /// RFC 3339 timestamp of the last migration.
20    pub migrated_at: String,
21}
22
23impl VersionFile {
24    /// Read from `<dir>/version.json`.  Returns `None` if the file is absent
25    /// (treated as version 0).
26    ///
27    /// # Errors
28    ///
29    /// Returns [`StorageError`] if the file exists but cannot be parsed.
30    pub fn read(dir: &Path) -> Result<Option<Self>, StorageError> {
31        let path = dir.join("version.json");
32        if !path.exists() {
33            return Ok(None);
34        }
35        let data = std::fs::read_to_string(&path)?;
36        let v: Self = serde_json::from_str(&data)?;
37        Ok(Some(v))
38    }
39
40    /// Write to `<dir>/version.json`.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`StorageError::Io`] or [`StorageError::Json`] on failure.
45    pub fn write(&self, dir: &Path) -> Result<(), StorageError> {
46        std::fs::create_dir_all(dir)?;
47        let json = serde_json::to_string_pretty(self)?;
48        std::fs::write(dir.join("version.json"), json)?;
49        Ok(())
50    }
51}
52
53/// A single migration step from `from_version` to `from_version + 1`.
54pub trait MigrationStep: Send + Sync {
55    /// The version this step upgrades *from* (upgrades to `from_version + 1`).
56    #[allow(clippy::wrong_self_convention)]
57    fn from_version(&self) -> u32;
58
59    /// Apply the migration.  `data_dir` is the directory containing the data
60    /// to be migrated.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`StorageError::Migration`] or other storage errors on failure.
65    fn run(&self, data_dir: &Path) -> Result<(), StorageError>;
66}
67
68/// Runs schema migrations for a storage subsystem.
69///
70/// Given the current schema version (from `version.json`) and a target
71/// version, applies each [`MigrationStep`] in order.
72pub struct StorageMigration {
73    /// Ordered list of migration steps (step `i` upgrades from version `i`).
74    steps: Vec<Box<dyn MigrationStep>>,
75    /// Target schema version.
76    target_version: u32,
77}
78
79impl StorageMigration {
80    /// Create a new migration runner with the given ordered steps.
81    #[must_use]
82    pub fn new(steps: Vec<Box<dyn MigrationStep>>, target_version: u32) -> Self {
83        Self {
84            steps,
85            target_version,
86        }
87    }
88
89    /// Run any necessary migrations for `data_dir`, updating `version.json`
90    /// after each successful step.
91    ///
92    /// Uses a **copy-then-swap** strategy: each step operates on a scratch
93    /// directory, then the result is atomically renamed into place.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`StorageError::Migration`] if any step fails.
98    pub fn migrate(&self, data_dir: &Path) -> Result<(), StorageError> {
99        std::fs::create_dir_all(data_dir)?;
100
101        let current = VersionFile::read(data_dir)?.map_or(0, |v| v.version);
102        if current >= self.target_version {
103            return Ok(());
104        }
105
106        for step in &self.steps {
107            let from = step.from_version();
108            if from < current {
109                continue; // Already applied.
110            }
111            if from >= self.target_version {
112                break;
113            }
114
115            step.run(data_dir).map_err(|e| StorageError::Migration {
116                from,
117                to: from + 1,
118                reason: e.to_string(),
119            })?;
120
121            let vf = VersionFile {
122                version: from + 1,
123                migrated_at: chrono::Utc::now().to_rfc3339(),
124            };
125            vf.write(data_dir)?;
126        }
127
128        Ok(())
129    }
130
131    /// Current target version this runner expects.
132    #[must_use]
133    pub const fn target_version(&self) -> u32 {
134        self.target_version
135    }
136}
137
138/// A no-op migration step used in tests.
139pub struct NoOpMigrationStep {
140    from: u32,
141}
142
143impl NoOpMigrationStep {
144    /// Create a step that does nothing.
145    #[must_use]
146    pub const fn new(from: u32) -> Self {
147        Self { from }
148    }
149}
150
151impl MigrationStep for NoOpMigrationStep {
152    fn from_version(&self) -> u32 {
153        self.from
154    }
155
156    fn run(&self, _data_dir: &Path) -> Result<(), StorageError> {
157        Ok(())
158    }
159}
160
161#[cfg(test)]
162#[allow(clippy::expect_used, clippy::unwrap_used)]
163mod tests {
164    use super::*;
165    use tempfile::tempdir;
166
167    #[test]
168    fn migration_from_zero_to_target() {
169        let dir = tempdir().expect("tempdir");
170        let runner = StorageMigration::new(vec![Box::new(NoOpMigrationStep::new(0))], 1);
171        runner.migrate(dir.path()).expect("migrate");
172        let vf = VersionFile::read(dir.path())
173            .expect("read")
174            .expect("version file");
175        assert_eq!(vf.version, 1);
176    }
177
178    #[test]
179    fn migration_skips_if_already_at_target() {
180        let dir = tempdir().expect("tempdir");
181        // Pre-write version 2.
182        let vf = VersionFile {
183            version: 2,
184            migrated_at: "2026-01-01T00:00:00Z".to_owned(),
185        };
186        vf.write(dir.path()).expect("write version");
187
188        let runner = StorageMigration::new(
189            vec![
190                Box::new(NoOpMigrationStep::new(0)),
191                Box::new(NoOpMigrationStep::new(1)),
192            ],
193            2,
194        );
195        runner.migrate(dir.path()).expect("migrate");
196        let vf2 = VersionFile::read(dir.path())
197            .expect("read")
198            .expect("version file");
199        // Should still be at version 2 (no step ran).
200        assert_eq!(vf2.version, 2);
201    }
202
203    #[test]
204    fn version_file_round_trips() {
205        let dir = tempdir().expect("tempdir");
206        let vf = VersionFile {
207            version: 42,
208            migrated_at: "2026-03-16T12:00:00Z".to_owned(),
209        };
210        vf.write(dir.path()).expect("write");
211        let read_back = VersionFile::read(dir.path())
212            .expect("read")
213            .expect("present");
214        assert_eq!(read_back.version, 42);
215    }
216}