Skip to main content

oximedia_workflow/
workflow_migration.rs

1#![allow(dead_code)]
2//! Workflow schema migration and version upgrade utilities.
3//!
4//! Provides a framework for migrating workflow definitions between schema
5//! versions, enabling backwards compatibility and safe rollouts of workflow
6//! changes across the OxiMedia platform.
7
8use std::collections::HashMap;
9use std::fmt;
10
11/// A semantic version triplet for workflow schemas.
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct SchemaVersion {
14    /// Major version (breaking changes).
15    pub major: u32,
16    /// Minor version (backwards-compatible additions).
17    pub minor: u32,
18    /// Patch version (bug fixes).
19    pub patch: u32,
20}
21
22impl SchemaVersion {
23    /// Create a new schema version.
24    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
25        Self {
26            major,
27            minor,
28            patch,
29        }
30    }
31
32    /// Check if this version is compatible with another (same major version).
33    pub fn is_compatible_with(&self, other: &Self) -> bool {
34        self.major == other.major
35    }
36
37    /// Check if this version is newer than another.
38    pub fn is_newer_than(&self, other: &Self) -> bool {
39        self > other
40    }
41}
42
43impl fmt::Display for SchemaVersion {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
46    }
47}
48
49/// Errors that can occur during migration.
50#[derive(Debug, Clone, PartialEq)]
51pub enum MigrationError {
52    /// No migration path exists between the two versions.
53    NoPath {
54        /// The version migrating from.
55        from: SchemaVersion,
56        /// The version migrating to.
57        to: SchemaVersion,
58    },
59    /// A migration step failed.
60    StepFailed {
61        /// Description of the step.
62        step: String,
63        /// Reason for failure.
64        reason: String,
65    },
66    /// The definition is already at the target version.
67    AlreadyCurrent,
68    /// The target version is older than the current version.
69    DowngradeNotSupported,
70    /// Validation failed after migration.
71    ValidationFailed(String),
72}
73
74impl fmt::Display for MigrationError {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            Self::NoPath { from, to } => {
78                write!(f, "no migration path from {from} to {to}")
79            }
80            Self::StepFailed { step, reason } => {
81                write!(f, "migration step '{step}' failed: {reason}")
82            }
83            Self::AlreadyCurrent => write!(f, "already at target version"),
84            Self::DowngradeNotSupported => write!(f, "downgrade not supported"),
85            Self::ValidationFailed(msg) => write!(f, "validation failed: {msg}"),
86        }
87    }
88}
89
90/// A single field change within a migration step.
91#[derive(Debug, Clone, PartialEq)]
92pub enum FieldChange {
93    /// Add a new field with a default value.
94    AddField {
95        /// Name of the field.
96        name: String,
97        /// Default value as a string.
98        default: String,
99    },
100    /// Remove a field.
101    RemoveField {
102        /// Name of the field to remove.
103        name: String,
104    },
105    /// Rename a field.
106    RenameField {
107        /// Original name.
108        from: String,
109        /// New name.
110        to: String,
111    },
112    /// Change the type of a field (with a conversion description).
113    ChangeType {
114        /// Name of the field.
115        name: String,
116        /// Description of the type change.
117        description: String,
118    },
119}
120
121/// A single migration step between adjacent versions.
122#[derive(Debug, Clone)]
123pub struct MigrationStep {
124    /// Version this step migrates from.
125    pub from: SchemaVersion,
126    /// Version this step migrates to.
127    pub to: SchemaVersion,
128    /// Human-readable description of the migration.
129    pub description: String,
130    /// Field changes applied by this step.
131    pub changes: Vec<FieldChange>,
132}
133
134impl MigrationStep {
135    /// Create a new migration step.
136    pub fn new(from: SchemaVersion, to: SchemaVersion, description: impl Into<String>) -> Self {
137        Self {
138            from,
139            to,
140            description: description.into(),
141            changes: Vec::new(),
142        }
143    }
144
145    /// Add a field change to this step.
146    pub fn add_change(&mut self, change: FieldChange) {
147        self.changes.push(change);
148    }
149
150    /// Apply this migration step to a workflow definition (key-value map).
151    pub fn apply(&self, definition: &mut HashMap<String, String>) -> Result<(), MigrationError> {
152        for change in &self.changes {
153            match change {
154                FieldChange::AddField { name, default } => {
155                    definition
156                        .entry(name.clone())
157                        .or_insert_with(|| default.clone());
158                }
159                FieldChange::RemoveField { name } => {
160                    definition.remove(name);
161                }
162                FieldChange::RenameField { from, to } => {
163                    if let Some(value) = definition.remove(from) {
164                        definition.insert(to.clone(), value);
165                    }
166                }
167                FieldChange::ChangeType {
168                    name,
169                    description: _,
170                } => {
171                    if !definition.contains_key(name) {
172                        return Err(MigrationError::StepFailed {
173                            step: self.description.clone(),
174                            reason: format!("field '{name}' not found for type change"),
175                        });
176                    }
177                }
178            }
179        }
180        // Update the version stamp in the definition.
181        definition.insert("_schema_version".to_string(), self.to.to_string());
182        Ok(())
183    }
184}
185
186/// Registry of migration steps that can find paths between versions.
187#[derive(Debug, Clone)]
188pub struct MigrationRegistry {
189    /// All registered migration steps.
190    steps: Vec<MigrationStep>,
191}
192
193impl MigrationRegistry {
194    /// Create an empty registry.
195    pub fn new() -> Self {
196        Self { steps: Vec::new() }
197    }
198
199    /// Register a migration step.
200    pub fn register(&mut self, step: MigrationStep) {
201        self.steps.push(step);
202    }
203
204    /// Find a migration path from one version to another.
205    pub fn find_path(
206        &self,
207        from: &SchemaVersion,
208        to: &SchemaVersion,
209    ) -> Result<Vec<&MigrationStep>, MigrationError> {
210        if from == to {
211            return Err(MigrationError::AlreadyCurrent);
212        }
213        if from > to {
214            return Err(MigrationError::DowngradeNotSupported);
215        }
216
217        let mut path = Vec::new();
218        let mut current = from.clone();
219
220        while current != *to {
221            let step = self
222                .steps
223                .iter()
224                .find(|s| s.from == current)
225                .ok_or_else(|| MigrationError::NoPath {
226                    from: current.clone(),
227                    to: to.clone(),
228                })?;
229            if step.to > *to {
230                return Err(MigrationError::NoPath {
231                    from: from.clone(),
232                    to: to.clone(),
233                });
234            }
235            current = step.to.clone();
236            path.push(step);
237        }
238
239        Ok(path)
240    }
241
242    /// Execute a full migration on a definition.
243    pub fn migrate(
244        &self,
245        definition: &mut HashMap<String, String>,
246        from: &SchemaVersion,
247        to: &SchemaVersion,
248    ) -> Result<Vec<String>, MigrationError> {
249        let path = self.find_path(from, to)?;
250        let mut descriptions = Vec::new();
251        for step in path {
252            step.apply(definition)?;
253            descriptions.push(step.description.clone());
254        }
255        Ok(descriptions)
256    }
257
258    /// Return how many steps are registered.
259    pub fn step_count(&self) -> usize {
260        self.steps.len()
261    }
262
263    /// List all registered versions (source and destination).
264    pub fn known_versions(&self) -> Vec<SchemaVersion> {
265        let mut versions: Vec<SchemaVersion> = self
266            .steps
267            .iter()
268            .flat_map(|s| vec![s.from.clone(), s.to.clone()])
269            .collect();
270        versions.sort();
271        versions.dedup();
272        versions
273    }
274}
275
276impl Default for MigrationRegistry {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    fn v(major: u32, minor: u32, patch: u32) -> SchemaVersion {
287        SchemaVersion::new(major, minor, patch)
288    }
289
290    #[test]
291    fn test_version_display() {
292        assert_eq!(v(1, 2, 3).to_string(), "1.2.3");
293        assert_eq!(v(0, 0, 1).to_string(), "0.0.1");
294    }
295
296    #[test]
297    fn test_version_compatibility() {
298        assert!(v(1, 0, 0).is_compatible_with(&v(1, 5, 3)));
299        assert!(!v(1, 0, 0).is_compatible_with(&v(2, 0, 0)));
300    }
301
302    #[test]
303    fn test_version_ordering() {
304        assert!(v(1, 0, 0).is_newer_than(&v(0, 9, 9)));
305        assert!(v(1, 1, 0).is_newer_than(&v(1, 0, 9)));
306        assert!(!v(1, 0, 0).is_newer_than(&v(1, 0, 0)));
307    }
308
309    #[test]
310    fn test_migration_step_add_field() {
311        let mut step = MigrationStep::new(v(1, 0, 0), v(1, 1, 0), "add priority");
312        step.add_change(FieldChange::AddField {
313            name: "priority".into(),
314            default: "normal".into(),
315        });
316
317        let mut def = HashMap::new();
318        def.insert("name".into(), "test".into());
319        step.apply(&mut def).expect("should succeed in test");
320        assert_eq!(
321            def.get("priority").expect("should succeed in test"),
322            "normal"
323        );
324        assert_eq!(
325            def.get("_schema_version").expect("should succeed in test"),
326            "1.1.0"
327        );
328    }
329
330    #[test]
331    fn test_migration_step_remove_field() {
332        let mut step = MigrationStep::new(v(1, 0, 0), v(1, 1, 0), "remove legacy");
333        step.add_change(FieldChange::RemoveField {
334            name: "legacy_flag".into(),
335        });
336
337        let mut def = HashMap::new();
338        def.insert("legacy_flag".into(), "true".into());
339        step.apply(&mut def).expect("should succeed in test");
340        assert!(!def.contains_key("legacy_flag"));
341    }
342
343    #[test]
344    fn test_migration_step_rename_field() {
345        let mut step = MigrationStep::new(v(1, 0, 0), v(1, 1, 0), "rename field");
346        step.add_change(FieldChange::RenameField {
347            from: "old_name".into(),
348            to: "new_name".into(),
349        });
350
351        let mut def = HashMap::new();
352        def.insert("old_name".into(), "value".into());
353        step.apply(&mut def).expect("should succeed in test");
354        assert!(!def.contains_key("old_name"));
355        assert_eq!(
356            def.get("new_name").expect("should succeed in test"),
357            "value"
358        );
359    }
360
361    #[test]
362    fn test_change_type_missing_field() {
363        let mut step = MigrationStep::new(v(1, 0, 0), v(1, 1, 0), "change type");
364        step.add_change(FieldChange::ChangeType {
365            name: "missing".into(),
366            description: "int -> str".into(),
367        });
368
369        let mut def = HashMap::new();
370        let result = step.apply(&mut def);
371        assert!(matches!(result, Err(MigrationError::StepFailed { .. })));
372    }
373
374    #[test]
375    fn test_registry_find_path() {
376        let mut reg = MigrationRegistry::new();
377        reg.register(MigrationStep::new(v(1, 0, 0), v(1, 1, 0), "step1"));
378        reg.register(MigrationStep::new(v(1, 1, 0), v(1, 2, 0), "step2"));
379
380        let path = reg
381            .find_path(&v(1, 0, 0), &v(1, 2, 0))
382            .expect("should succeed in test");
383        assert_eq!(path.len(), 2);
384        assert_eq!(path[0].description, "step1");
385        assert_eq!(path[1].description, "step2");
386    }
387
388    #[test]
389    fn test_registry_already_current() {
390        let reg = MigrationRegistry::new();
391        let result = reg.find_path(&v(1, 0, 0), &v(1, 0, 0));
392        assert!(matches!(result, Err(MigrationError::AlreadyCurrent)));
393    }
394
395    #[test]
396    fn test_registry_downgrade_not_supported() {
397        let reg = MigrationRegistry::new();
398        let result = reg.find_path(&v(2, 0, 0), &v(1, 0, 0));
399        assert!(matches!(result, Err(MigrationError::DowngradeNotSupported)));
400    }
401
402    #[test]
403    fn test_registry_no_path() {
404        let mut reg = MigrationRegistry::new();
405        reg.register(MigrationStep::new(v(1, 0, 0), v(1, 1, 0), "step1"));
406        let result = reg.find_path(&v(1, 0, 0), &v(1, 3, 0));
407        assert!(matches!(result, Err(MigrationError::NoPath { .. })));
408    }
409
410    #[test]
411    fn test_full_migration() {
412        let mut reg = MigrationRegistry::new();
413        let mut s1 = MigrationStep::new(v(1, 0, 0), v(1, 1, 0), "add timeout");
414        s1.add_change(FieldChange::AddField {
415            name: "timeout".into(),
416            default: "30".into(),
417        });
418        let mut s2 = MigrationStep::new(v(1, 1, 0), v(1, 2, 0), "rename output");
419        s2.add_change(FieldChange::RenameField {
420            from: "dest".into(),
421            to: "output_path".into(),
422        });
423        reg.register(s1);
424        reg.register(s2);
425
426        let mut def = HashMap::new();
427        def.insert("name".into(), "wf1".into());
428        def.insert("dest".into(), "/out".into());
429
430        let descs = reg
431            .migrate(&mut def, &v(1, 0, 0), &v(1, 2, 0))
432            .expect("should succeed in test");
433        assert_eq!(descs.len(), 2);
434        assert_eq!(def.get("timeout").expect("should succeed in test"), "30");
435        assert_eq!(
436            def.get("output_path").expect("should succeed in test"),
437            "/out"
438        );
439        assert!(!def.contains_key("dest"));
440    }
441
442    #[test]
443    fn test_known_versions() {
444        let mut reg = MigrationRegistry::new();
445        reg.register(MigrationStep::new(v(1, 0, 0), v(1, 1, 0), "s1"));
446        reg.register(MigrationStep::new(v(1, 1, 0), v(2, 0, 0), "s2"));
447
448        let versions = reg.known_versions();
449        assert_eq!(versions.len(), 3);
450        assert_eq!(versions[0], v(1, 0, 0));
451        assert_eq!(versions[1], v(1, 1, 0));
452        assert_eq!(versions[2], v(2, 0, 0));
453    }
454
455    #[test]
456    fn test_step_count() {
457        let mut reg = MigrationRegistry::new();
458        assert_eq!(reg.step_count(), 0);
459        reg.register(MigrationStep::new(v(1, 0, 0), v(1, 1, 0), "s1"));
460        assert_eq!(reg.step_count(), 1);
461    }
462}