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