Skip to main content

teksilo_settings/
migration.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Schema migrations for persisted files.
5//!
6//! Every persisted struct carries a `version: u32` (via [`Versioned`]).
7//! [`Migrator<T>`] holds an ordered set of `from_version → from_version + 1`
8//! transformations expressed on raw [`toml::Value`] — pre-deserialization,
9//! so a v1 file that no longer matches the v2 type can still be upgraded.
10//!
11//! ```
12//! use teksilo_settings::{Versioned, Migrator};
13//! use serde::{Serialize, Deserialize};
14//!
15//! #[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
16//! struct Recents {
17//!     version: u32,
18//!     items: Vec<Entry>,
19//! }
20//!
21//! #[derive(Serialize, Deserialize, Debug, PartialEq)]
22//! struct Entry { path: String, pinned: bool }
23//!
24//! impl Versioned for Recents {
25//!     const CURRENT_VERSION: u32 = 2;
26//!     fn version(&self) -> u32 { self.version }
27//!     fn set_version(&mut self, v: u32) { self.version = v; }
28//! }
29//!
30//! // v1 didn't have `pinned`; supply false.
31//! let migrator: Migrator<Recents> = Migrator::new()
32//!     .step(1, |mut v| {
33//!         if let Some(items) = v.get_mut("items").and_then(|i| i.as_array_mut()) {
34//!             for item in items {
35//!                 if let Some(t) = item.as_table_mut() {
36//!                     t.insert("pinned".into(), toml::Value::Boolean(false));
37//!                 }
38//!             }
39//!         }
40//!         Ok(v)
41//!     });
42//! ```
43
44use std::marker::PhantomData;
45use std::sync::Arc;
46
47use serde::de::DeserializeOwned;
48
49/// A persisted struct whose schema is versioned.
50///
51/// `CURRENT_VERSION` is the version this build of the code reads and
52/// writes. Files on disk may be older — [`Migrator`] walks them up.
53pub trait Versioned {
54    /// The version this build understands. Bump when the schema changes
55    /// in a way that requires migration.
56    const CURRENT_VERSION: u32;
57
58    /// The version embedded in this instance.
59    fn version(&self) -> u32;
60
61    /// Write a new version into this instance. Used by the migrator
62    /// after a successful chain of steps.
63    fn set_version(&mut self, v: u32);
64}
65
66/// Errors surfaced by [`Migrator::run`].
67#[derive(Debug, thiserror::Error)]
68pub enum MigrationError {
69    /// The on-disk version number exceeds `T::CURRENT_VERSION`; the
70    /// file was written by a newer build and cannot be read safely.
71    #[error("settings file is version {on_disk}, but this build only reads up to {current}")]
72    NewerThanCurrent { on_disk: u32, current: u32 },
73    /// The chain is missing a step for the encountered version, making
74    /// it impossible to reach `T::CURRENT_VERSION`.
75    #[error("no migration step registered for settings version {0}")]
76    NoStepFor(u32),
77    /// A migration step closure returned `Err(message)`.
78    #[error("migration step {from} -> {} failed: {message}", from + 1)]
79    Step { from: u32, message: String },
80    /// The migrated `toml::Value` did not deserialize as `T`.
81    #[error("post-migration deserialization: {0}")]
82    Deserialize(#[source] toml::de::Error),
83}
84
85type StepFn = Arc<dyn Fn(toml::Value) -> Result<toml::Value, String> + Send + Sync>;
86
87#[derive(Clone)]
88struct Step {
89    from: u32,
90    func: StepFn,
91}
92
93/// Schema migration pipeline for a [`Versioned`] type.
94///
95/// Add `from → from + 1` steps with [`Migrator::step`]; the order in which
96/// they're added does not matter — [`Migrator::run`] walks them in
97/// version order.
98///
99/// `Migrator<T>` is cheaply [`Clone`] (each step's closure lives behind an
100/// `Arc`, so cloning is a handful of refcount bumps, not a deep copy) and
101/// `Send + Sync` whenever `T` is — which is what lets a `Patch`
102/// (`crate::flush::Patch`) closure retain its own copy of the migrator and re-run it against the
103/// document read fresh on the shared I/O worker thread, instead of the
104/// stale, possibly-out-of-date value this handle loaded at construction.
105pub struct Migrator<T: Versioned + DeserializeOwned> {
106    steps: Vec<Step>,
107    _marker: PhantomData<T>,
108}
109
110impl<T: Versioned + DeserializeOwned> Migrator<T> {
111    /// Create an empty migrator with no steps registered.
112    ///
113    /// If `T::CURRENT_VERSION` is 1 (the initial schema) or the file
114    /// is already at the current version, no steps are needed and
115    /// `run` will succeed immediately.
116    pub fn new() -> Self {
117        Self {
118            steps: Vec::new(),
119            _marker: PhantomData,
120        }
121    }
122
123    /// Register a step that promotes a value from `from` to `from + 1`.
124    /// Steps may be registered in any order; [`run`](Self::run) finds
125    /// the right one for the current version on demand.
126    pub fn step<F>(mut self, from: u32, func: F) -> Self
127    where
128        F: Fn(toml::Value) -> Result<toml::Value, String> + Send + Sync + 'static,
129    {
130        self.steps.push(Step {
131            from,
132            func: Arc::new(func),
133        });
134        self
135    }
136
137    /// Migrate `raw` from its on-disk version up to
138    /// `T::CURRENT_VERSION`, then deserialize.
139    ///
140    /// Reads the version directly from the `version` field of the raw
141    /// `toml::Value` — never deserializes-then-checks, because a v1
142    /// payload typically fails to deserialize as the v2 type.
143    ///
144    /// Files missing the `version` field are treated as v1 (legacy).
145    pub fn run(&self, mut raw: toml::Value) -> Result<T, MigrationError> {
146        let target = T::CURRENT_VERSION;
147        let mut current = peek_version(&raw).unwrap_or(1);
148
149        if current > target {
150            return Err(MigrationError::NewerThanCurrent {
151                on_disk: current,
152                current: target,
153            });
154        }
155
156        while current < target {
157            let step = self
158                .steps
159                .iter()
160                .find(|s| s.from == current)
161                .ok_or(MigrationError::NoStepFor(current))?;
162            raw = (step.func)(raw).map_err(|message| MigrationError::Step {
163                from: current,
164                message,
165            })?;
166            current += 1;
167            // Stamp the new version on the raw value so each subsequent
168            // step (and the final deserialize) sees a coherent struct.
169            if let Some(table) = raw.as_table_mut() {
170                table.insert("version".into(), toml::Value::Integer(current as i64));
171            }
172        }
173
174        T::deserialize(raw).map_err(MigrationError::Deserialize)
175    }
176}
177
178impl<T: Versioned + DeserializeOwned> Default for Migrator<T> {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184impl<T: Versioned + DeserializeOwned> Clone for Migrator<T> {
185    /// Hand-written rather than `#[derive(Clone)]`: a derive would add a
186    /// spurious `T: Clone` bound (from the `PhantomData<T>` field) even
187    /// though nothing here actually needs it — `Vec<Step>` clones just fine
188    /// on its own (each `Step::func` is an `Arc`, so this is a handful of
189    /// refcount bumps).
190    fn clone(&self) -> Self {
191        Self {
192            steps: self.steps.clone(),
193            _marker: PhantomData,
194        }
195    }
196}
197
198impl<T: Versioned + DeserializeOwned> std::fmt::Debug for Migrator<T> {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.debug_struct("Migrator")
201            .field("step_count", &self.steps.len())
202            .field("target_type", &std::any::type_name::<T>())
203            .finish()
204    }
205}
206
207fn peek_version(raw: &toml::Value) -> Option<u32> {
208    raw.get("version")
209        .and_then(|v| v.as_integer())
210        .and_then(|n| u32::try_from(n).ok())
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use serde::{Deserialize, Serialize};
217
218    #[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
219    struct V2 {
220        version: u32,
221        name: String,
222        pinned: bool,
223    }
224
225    impl Versioned for V2 {
226        const CURRENT_VERSION: u32 = 2;
227        fn version(&self) -> u32 {
228            self.version
229        }
230        fn set_version(&mut self, v: u32) {
231            self.version = v;
232        }
233    }
234
235    #[test]
236    fn no_op_when_already_current() {
237        let raw: toml::Value = toml::from_str("version = 2\nname = \"x\"\npinned = true").unwrap();
238        let migrator: Migrator<V2> = Migrator::new();
239        let v = migrator.run(raw).unwrap();
240        assert_eq!(
241            v,
242            V2 {
243                version: 2,
244                name: "x".into(),
245                pinned: true
246            }
247        );
248    }
249
250    #[test]
251    fn applies_one_step() {
252        let raw: toml::Value = toml::from_str("version = 1\nname = \"x\"").unwrap();
253        let migrator: Migrator<V2> = Migrator::new().step(1, |mut v| {
254            if let Some(t) = v.as_table_mut() {
255                t.insert("pinned".into(), toml::Value::Boolean(false));
256            }
257            Ok(v)
258        });
259        let v = migrator.run(raw).unwrap();
260        assert_eq!(
261            v,
262            V2 {
263                version: 2,
264                name: "x".into(),
265                pinned: false
266            }
267        );
268    }
269
270    #[test]
271    fn missing_version_treated_as_v1() {
272        // Legacy file with no `version =` at all: assumed v1.
273        let raw: toml::Value = toml::from_str("name = \"y\"").unwrap();
274        let migrator: Migrator<V2> = Migrator::new().step(1, |mut v| {
275            if let Some(t) = v.as_table_mut() {
276                t.insert("pinned".into(), toml::Value::Boolean(true));
277            }
278            Ok(v)
279        });
280        let v = migrator.run(raw).unwrap();
281        assert!(v.pinned);
282        assert_eq!(v.version, 2);
283    }
284
285    #[test]
286    fn newer_than_current_errors() {
287        let raw: toml::Value = toml::from_str("version = 7\nname = \"x\"").unwrap();
288        let migrator: Migrator<V2> = Migrator::new();
289        let err = migrator.run(raw).unwrap_err();
290        assert!(matches!(
291            err,
292            MigrationError::NewerThanCurrent {
293                on_disk: 7,
294                current: 2
295            }
296        ));
297    }
298
299    #[test]
300    fn missing_step_errors() {
301        let raw: toml::Value = toml::from_str("version = 1\nname = \"x\"").unwrap();
302        // No step for v1 -> v2 registered.
303        let migrator: Migrator<V2> = Migrator::new();
304        let err = migrator.run(raw).unwrap_err();
305        assert!(matches!(err, MigrationError::NoStepFor(1)));
306    }
307
308    #[test]
309    fn step_failure_propagates() {
310        let raw: toml::Value = toml::from_str("version = 1\nname = \"x\"").unwrap();
311        let migrator: Migrator<V2> = Migrator::new().step(1, |_| Err("borked".into()));
312        match migrator.run(raw).unwrap_err() {
313            MigrationError::Step { from, message } => {
314                assert_eq!(from, 1);
315                assert_eq!(message, "borked");
316            }
317            other => panic!("unexpected error: {other:?}"),
318        }
319    }
320
321    #[test]
322    fn multi_step_chain_walks_in_order() {
323        #[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
324        struct V3 {
325            version: u32,
326            a: i32,
327            b: i32,
328            c: i32,
329        }
330        impl Versioned for V3 {
331            const CURRENT_VERSION: u32 = 3;
332            fn version(&self) -> u32 {
333                self.version
334            }
335            fn set_version(&mut self, v: u32) {
336                self.version = v;
337            }
338        }
339
340        let raw: toml::Value = toml::from_str("version = 1\na = 1").unwrap();
341        let migrator: Migrator<V3> = Migrator::new()
342            .step(2, |mut v| {
343                v.as_table_mut().unwrap().insert("c".into(), 3.into());
344                Ok(v)
345            })
346            // intentionally registered out of order
347            .step(1, |mut v| {
348                v.as_table_mut().unwrap().insert("b".into(), 2.into());
349                Ok(v)
350            });
351
352        let v = migrator.run(raw).unwrap();
353        assert_eq!(
354            v,
355            V3 {
356                version: 3,
357                a: 1,
358                b: 2,
359                c: 3
360            }
361        );
362    }
363}