Skip to main content

vyre_driver/registry/
migration.rs

1//! Op versioning, attribute migration, and deprecation registration.
2//!
3//! Ops evolve. `math.add@1` may gain an `overflow_behavior` attribute
4//! in `math.add@2` and rename `mode` in the process. Payloads encoded
5//! against v1 must still decode on a runtime that only knows v2.
6//!
7//! This module carries three inventory-collected registries:
8//!
9//! * [`Migration`]  -  a one-step rewrite from `(op_id, from_version)`
10//!   to `(op_id, to_version)` operating on an [`AttrMap`]. Migrations
11//!   chain automatically: if v1→v2 and v2→v3 are registered, a v1
12//!   payload decodes as v3.
13//! * [`Deprecation`]  -  marks an op as deprecated since a specific
14//!   version, with a note that becomes part of the
15//!   [`deprecation_diagnostic`] warning surfaced to the caller.
16//! * Decoders consult these tables before validating an op against the
17//!   final schema. This module ships the registries and public API so
18//!   dialect crates register migrations next to the evolving op.
19//!
20//! Design notes:
21//!
22//! * Attribute values are typed (see [`AttrValue`]). A migration can
23//!   inspect the existing shape before rewriting  -  no stringly-typed
24//!   dance inside the hot decode path.
25//! * Migrations are `fn` pointers, not closures. This keeps
26//!   `Migration` `'static` and safe to stash behind `inventory::iter`.
27//! * Chain resolution stops at the highest version reachable. An
28//!   absent further migration is a terminal state, not an error.
29
30use std::sync::OnceLock;
31
32use crate::diagnostics::{Diagnostic, OpLocation};
33use rustc_hash::FxHashMap;
34
35/// Semantic version triple used for op versioning.
36///
37/// The registry's current `Dialect::version` is still a single `u32`;
38/// the triple form is the canonical representation for per-op
39/// evolution: minor bumps are backward-compatible additions and patch
40/// bumps are bug fixes. The `Ord` impl is lexicographic major→minor→
41/// patch so ordinary comparison works for chain resolution.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub struct Semver {
44    /// Breaking-change counter.
45    pub major: u32,
46    /// Backwards-compatible-feature counter.
47    pub minor: u32,
48    /// Patch counter.
49    pub patch: u32,
50}
51
52impl Semver {
53    /// Construct a new semver triple.
54    #[must_use]
55    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
56        Self {
57            major,
58            minor,
59            patch,
60        }
61    }
62}
63
64impl std::fmt::Display for Semver {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
67    }
68}
69
70/// Typed attribute value carried in an [`AttrMap`].
71///
72/// The tags match [`vyre_foundation::AttrType`] one-to-one so
73/// a migration can round-trip an attribute through the op's schema
74/// without losing type information.
75#[derive(Debug, Clone, PartialEq)]
76#[non_exhaustive]
77pub enum AttrValue {
78    /// Unsigned 32-bit integer.
79    U32(u32),
80    /// Signed 32-bit integer.
81    I32(i32),
82    /// 32-bit float.
83    F32(f32),
84    /// Boolean flag.
85    Bool(bool),
86    /// Opaque byte blob.
87    Bytes(Vec<u8>),
88    /// UTF-8 string.
89    String(String),
90}
91
92/// Mutable attribute bag passed to [`Migration::rewrite`].
93///
94/// The migration typically renames keys, coerces values, or
95/// inserts defaults for newly-introduced attributes. The wire
96/// decoder constructs one of these per decoded op, hands it to the
97/// migration chain, and then validates against the final op's
98/// schema.
99#[derive(Debug, Default, Clone)]
100pub struct AttrMap {
101    attrs: FxHashMap<String, AttrValue>,
102}
103
104impl AttrMap {
105    /// Construct an empty attribute map.
106    #[must_use]
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Insert an attribute, returning the previous value if one
112    /// existed.
113    pub fn insert(&mut self, key: impl Into<String>, value: AttrValue) -> Option<AttrValue> {
114        self.attrs.insert(key.into(), value)
115    }
116
117    /// Remove an attribute by key, returning its value if present.
118    pub fn remove(&mut self, key: &str) -> Option<AttrValue> {
119        self.attrs.remove(key)
120    }
121
122    /// Fetch a reference to an attribute value.
123    #[must_use]
124    pub fn get(&self, key: &str) -> Option<&AttrValue> {
125        self.attrs.get(key)
126    }
127
128    /// Rename an attribute key. No-op when the source key is absent.
129    /// Returns `true` when a rename occurred.
130    pub fn rename(&mut self, from: &str, to: impl Into<String>) -> bool {
131        match self.attrs.remove(from) {
132            Some(v) => {
133                self.attrs.insert(to.into(), v);
134                true
135            }
136            None => false,
137        }
138    }
139
140    /// Number of attributes in the map.
141    #[must_use]
142    pub fn len(&self) -> usize {
143        self.attrs.len()
144    }
145
146    /// `true` when the map contains no attributes.
147    #[must_use]
148    pub fn is_empty(&self) -> bool {
149        self.attrs.is_empty()
150    }
151
152    /// Iterate `(key, value)` pairs in arbitrary order.
153    pub fn iter(&self) -> impl Iterator<Item = (&str, &AttrValue)> {
154        self.attrs.iter().map(|(k, v)| (k.as_str(), v))
155    }
156}
157
158/// Structured error returned by a [`Migration::rewrite`] function.
159///
160/// Migrations are fallible: a required input attribute may be
161/// missing, or a coerced value may not fit a narrower type. The
162/// error carries enough context for the decoder to surface a
163/// [`Diagnostic`] pinned to the offending op.
164#[derive(Debug, Clone, PartialEq, Eq)]
165#[non_exhaustive]
166pub enum MigrationError {
167    /// A required attribute was missing from the input map.
168    MissingAttribute {
169        /// Name of the missing attribute.
170        name: String,
171    },
172    /// An attribute carried the wrong type for the migration.
173    WrongType {
174        /// Name of the attribute.
175        name: String,
176        /// The expected type, as a human-readable tag.
177        expected: &'static str,
178    },
179    /// A coerced numeric value did not fit the narrower target type.
180    OutOfRange {
181        /// Name of the attribute that overflowed.
182        name: String,
183    },
184    /// The migration rejected the input for any other reason.
185    Custom {
186        /// Human-readable failure reason.
187        reason: String,
188    },
189}
190
191impl std::fmt::Display for MigrationError {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        match self {
194            MigrationError::MissingAttribute { name } => {
195                write!(f, "migration needs attribute `{name}` which is missing")
196            }
197            MigrationError::WrongType { name, expected } => {
198                write!(f, "migration expected `{name}` to be {expected}")
199            }
200            MigrationError::OutOfRange { name } => {
201                write!(f, "migration value for `{name}` is out of range")
202            }
203            MigrationError::Custom { reason } => f.write_str(reason),
204        }
205    }
206}
207
208impl std::error::Error for MigrationError {}
209
210/// One-step migration from `(op_id, from)` to `(op_id, to)`.
211///
212/// Dialect crates register migrations via:
213///
214/// ```
215/// use vyre_driver::registry::{AttrMap, Migration, MigrationError, Semver};
216///
217/// fn rename_mode(attrs: &mut AttrMap) -> Result<(), MigrationError> {
218///     attrs.rename("mode", "overflow_behavior");
219///     Ok(())
220/// }
221///
222/// inventory::submit! {
223///     Migration::new(
224///         ("math.add", Semver::new(1, 0, 0)),
225///         ("math.add", Semver::new(2, 0, 0)),
226///         rename_mode,
227///     )
228/// }
229/// ```
230///
231/// Multiple migrations form a chain. [`MigrationRegistry::apply_chain`]
232/// follows the chain to completion.
233pub struct Migration {
234    /// `(op_id, from_version)`  -  the shape on the wire.
235    pub from: (&'static str, Semver),
236    /// `(op_id, to_version)`  -  the shape after rewrite.
237    pub to: (&'static str, Semver),
238    /// The attribute-map rewrite function.
239    pub rewrite: fn(&mut AttrMap) -> Result<(), MigrationError>,
240}
241
242impl Migration {
243    /// Const constructor suited to `inventory::submit!` bodies.
244    #[must_use]
245    pub const fn new(
246        from: (&'static str, Semver),
247        to: (&'static str, Semver),
248        rewrite: fn(&mut AttrMap) -> Result<(), MigrationError>,
249    ) -> Self {
250        Self { from, to, rewrite }
251    }
252}
253
254inventory::collect!(Migration);
255
256/// Deprecation marker registered alongside an op.
257///
258/// The decoder consults the registry after successfully resolving an
259/// op; a hit produces a `Severity::Warning` diagnostic surfaced to
260/// the caller. Deprecation is a pure warning  -  decoding still
261/// succeeds.
262pub struct Deprecation {
263    /// The op identifier being deprecated.
264    pub op_id: &'static str,
265    /// The version at which the deprecation begins.
266    pub deprecated_since: Semver,
267    /// Human-readable migration note surfaced inside the warning.
268    pub note: &'static str,
269}
270
271impl Deprecation {
272    /// Const constructor suited to `inventory::submit!` bodies.
273    #[must_use]
274    pub const fn new(op_id: &'static str, deprecated_since: Semver, note: &'static str) -> Self {
275        Self {
276            op_id,
277            deprecated_since,
278            note,
279        }
280    }
281}
282
283inventory::collect!(Deprecation);
284
285/// Registry indexing migrations and deprecations for fast lookup.
286///
287/// Construction happens lazily on first `global()` call  -  every
288/// `inventory::submit!` in the workspace contributes. The registry
289/// is immutable after construction.
290pub struct MigrationRegistry {
291    // Keyed by (op_id, from_version). Value is the single migration
292    // registered for that step. Duplicate registrations collapse to
293    // the last-inserted for deterministic behavior.
294    forward: FxHashMap<(&'static str, Semver), &'static Migration>,
295    deprecations: FxHashMap<&'static str, &'static Deprecation>,
296}
297
298impl MigrationRegistry {
299    /// Process-wide singleton.
300    #[must_use]
301    pub fn global() -> &'static MigrationRegistry {
302        static REGISTRY: OnceLock<MigrationRegistry> = OnceLock::new();
303        REGISTRY.get_or_init(|| {
304            let migration_count = inventory::iter::<Migration>().count();
305            let mut forward = FxHashMap::default();
306            let _ = vyre_foundation::allocation::try_reserve_hash_map_to_capacity(
307                &mut forward,
308                migration_count,
309            );
310            let migrations = inventory::iter::<Migration>();
311            for m in migrations {
312                forward.insert((m.from.0, m.from.1), m);
313            }
314            let deprecation_count = inventory::iter::<Deprecation>().count();
315            let mut deprecations = FxHashMap::default();
316            vyre_foundation::allocation::try_reserve_hash_map_to_capacity(
317                &mut deprecations,
318                deprecation_count,
319            )
320            .ok();
321            let deprecation_defs = inventory::iter::<Deprecation>();
322            for d in deprecation_defs {
323                deprecations.insert(d.op_id, d);
324            }
325            MigrationRegistry {
326                forward,
327                deprecations,
328            }
329        })
330    }
331
332    /// Look up a single-step migration for `(op_id, from)`.
333    #[must_use]
334    pub fn lookup(&self, op_id: &str, from: Semver) -> Option<&'static Migration> {
335        self.forward.get(&(op_id, from)).copied()
336    }
337
338    /// Follow the migration chain starting at `(op_id, from)` and
339    /// rewrite `attrs` in place.
340    ///
341    /// Returns `(final_op_id, final_version)`  -  the `(op_id, to)`
342    /// pair of the last migration applied, or the input `(op_id,
343    /// from)` when no migration is registered. A failing rewrite
344    /// short-circuits and surfaces the [`MigrationError`].
345    ///
346    /// # Errors
347    ///
348    /// Propagates any [`MigrationError`] returned by a migration in
349    /// the chain.
350    pub fn apply_chain(
351        &self,
352        op_id: &'static str,
353        from: Semver,
354        attrs: &mut AttrMap,
355    ) -> Result<(&'static str, Semver), MigrationError> {
356        let mut current_op = op_id;
357        let mut current_ver = from;
358        // A migration's `to` is &'static str so we can keep the
359        // return type `&'static str` even after chain traversal.
360        loop {
361            let Some(m) = self.lookup(current_op, current_ver) else {
362                return Ok((current_op, current_ver));
363            };
364            (m.rewrite)(attrs)?;
365            current_op = m.to.0;
366            current_ver = m.to.1;
367        }
368    }
369
370    /// Fetch the deprecation marker for an op if one is registered.
371    #[must_use]
372    pub fn deprecation(&self, op_id: &str) -> Option<&'static Deprecation> {
373        self.deprecations.get(op_id).copied()
374    }
375}
376
377/// Build a `Severity::Warning` diagnostic for a deprecated op.
378///
379/// The decoder calls this after resolving a deprecated op and pushes
380/// the result onto its diagnostic buffer. The caller sees a
381/// machine-readable `W-OP-DEPRECATED` warning with the op location
382/// and migration note attached as the suggested fix.
383#[must_use]
384pub fn deprecation_diagnostic(dep: &Deprecation) -> Diagnostic {
385    Diagnostic::warning(
386        "W-OP-DEPRECATED",
387        format!(
388            "op `{}` is deprecated since version {}",
389            dep.op_id, dep.deprecated_since
390        ),
391    )
392    .with_location(OpLocation::op(dep.op_id.to_owned()))
393    .with_fix(dep.note)
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn rename_mode_to_overflow(attrs: &mut AttrMap) -> Result<(), MigrationError> {
401        if !attrs.rename("mode", "overflow_behavior") {
402            return Err(MigrationError::MissingAttribute {
403                name: "mode".into(),
404            });
405        }
406        Ok(())
407    }
408
409    // Register test-only migrations via inventory. These live in the
410    // test build only  -  no `cfg(test)` gate is needed on the
411    // inventory::submit! because the tests module itself is gated.
412    inventory::submit! {
413        Migration::new(
414            ("test.op_rename", Semver::new(1, 0, 0)),
415            ("test.op_rename", Semver::new(2, 0, 0)),
416            rename_mode_to_overflow,
417        )
418    }
419
420    inventory::submit! {
421        Migration::new(
422            ("test.op_chain", Semver::new(1, 0, 0)),
423            ("test.op_chain", Semver::new(2, 0, 0)),
424            |attrs| { attrs.rename("a", "b"); Ok(()) },
425        )
426    }
427
428    inventory::submit! {
429        Migration::new(
430            ("test.op_chain", Semver::new(2, 0, 0)),
431            ("test.op_chain", Semver::new(3, 0, 0)),
432            |attrs| { attrs.rename("b", "c"); Ok(()) },
433        )
434    }
435
436    inventory::submit! {
437        Deprecation::new(
438            "test.op_dep",
439            Semver::new(1, 1, 0),
440            "migrate to test.op_dep2",
441        )
442
443    }
444
445    #[test]
446    fn registry_finds_registered_migration() {
447        let reg = MigrationRegistry::global();
448        let m = reg.lookup("test.op_rename", Semver::new(1, 0, 0));
449        assert!(m.is_some(), "registered migration must be reachable");
450        let m = m.unwrap();
451        assert_eq!(m.to.1, Semver::new(2, 0, 0));
452    }
453
454    #[test]
455    fn apply_chain_rewrites_attributes() {
456        let reg = MigrationRegistry::global();
457        let mut attrs = AttrMap::new();
458        attrs.insert("mode", AttrValue::String("wrap".into()));
459        let (op, ver) = reg
460            .apply_chain("test.op_rename", Semver::new(1, 0, 0), &mut attrs)
461            .expect("Fix: migration registry missing the expected test op; ensure the #[test] fixture's inventory::submit! block is linked in this binary.");
462        assert_eq!(op, "test.op_rename");
463        assert_eq!(ver, Semver::new(2, 0, 0));
464        assert!(attrs.get("mode").is_none());
465        assert_eq!(
466            attrs.get("overflow_behavior"),
467            Some(&AttrValue::String("wrap".into()))
468        );
469    }
470
471    #[test]
472    fn apply_chain_follows_multiple_steps() {
473        let reg = MigrationRegistry::global();
474        let mut attrs = AttrMap::new();
475        attrs.insert("a", AttrValue::U32(1));
476        let (_, ver) = reg
477            .apply_chain("test.op_chain", Semver::new(1, 0, 0), &mut attrs)
478            .expect("Fix: migration registry missing the expected test op; ensure the #[test] fixture's inventory::submit! block is linked in this binary.");
479        assert_eq!(ver, Semver::new(3, 0, 0));
480        assert!(attrs.get("a").is_none());
481        assert!(attrs.get("b").is_none());
482        assert_eq!(attrs.get("c"), Some(&AttrValue::U32(1)));
483    }
484
485    #[test]
486    fn missing_source_attribute_surfaces_error() {
487        let reg = MigrationRegistry::global();
488        let mut attrs = AttrMap::new();
489        let err = reg
490            .apply_chain("test.op_rename", Semver::new(1, 0, 0), &mut attrs)
491            .expect_err("missing input must error");
492        assert!(matches!(err, MigrationError::MissingAttribute { .. }));
493    }
494
495    #[test]
496    fn no_migration_returns_input_unchanged() {
497        let reg = MigrationRegistry::global();
498        let mut attrs = AttrMap::new();
499        let (op, ver) = reg
500            .apply_chain("test.unregistered", Semver::new(1, 0, 0), &mut attrs)
501            .expect("Fix: apply_chain on an unregistered op must return Ok(input); if this errors, the no-migration terminal-state contract has regressed.");
502        assert_eq!(op, "test.unregistered");
503        assert_eq!(ver, Semver::new(1, 0, 0));
504    }
505
506    #[test]
507    fn deprecation_lookup_returns_marker() {
508        let reg = MigrationRegistry::global();
509        let dep = reg
510            .deprecation("test.op_dep")
511            .expect("Fix: test.op_dep deprecation registration missing; verify the fixture's inventory::submit! block is linked.");
512        assert_eq!(dep.deprecated_since, Semver::new(1, 1, 0));
513        assert_eq!(dep.note, "migrate to test.op_dep2");
514    }
515
516    #[test]
517    fn deprecation_diagnostic_has_warning_severity() {
518        let reg = MigrationRegistry::global();
519        let dep = reg.deprecation("test.op_dep").unwrap();
520        let diag = deprecation_diagnostic(dep);
521        assert_eq!(diag.severity, crate::diagnostics::Severity::Warning);
522        assert_eq!(diag.code.as_str(), "W-OP-DEPRECATED");
523        assert!(diag.message.contains("test.op_dep"));
524        assert!(diag
525            .suggested_fix
526            .as_ref()
527            .map(|s| s.contains("test.op_dep2"))
528            .unwrap_or(false));
529    }
530
531    #[test]
532    fn attr_map_basic_operations() {
533        let mut attrs = AttrMap::new();
534        assert!(attrs.is_empty());
535        attrs.insert("x", AttrValue::Bool(true));
536        assert_eq!(attrs.len(), 1);
537        assert_eq!(attrs.get("x"), Some(&AttrValue::Bool(true)));
538        let prev = attrs.insert("x", AttrValue::Bool(false));
539        assert_eq!(prev, Some(AttrValue::Bool(true)));
540        let removed = attrs.remove("x");
541        assert_eq!(removed, Some(AttrValue::Bool(false)));
542        assert!(attrs.is_empty());
543    }
544
545    #[test]
546    fn semver_ordering_is_lexicographic() {
547        assert!(Semver::new(1, 0, 0) < Semver::new(1, 0, 1));
548        assert!(Semver::new(1, 0, 5) < Semver::new(1, 1, 0));
549        assert!(Semver::new(1, 5, 5) < Semver::new(2, 0, 0));
550        assert_eq!(Semver::new(1, 2, 3).to_string(), "1.2.3");
551    }
552}