//! Type-safe, bidirectional schema migration with compile-time chain validation. //! //! This module provides an Alembic-inspired migration system where every version //! transition is a typed, bidirectional step between concrete Rust structs. //! //! # Architecture //! //! - [`MigrationStep`] — a single upgrade/downgrade between two concrete types //! - [`MigrationChain`] — runtime dispatch generated by the `migration_chain!` macro //! - `migration_chain!` — proc-macro (defined in `aimdb-derive`, re-exported here as //! `aimdb_data_contracts::migration_chain!`) that validates the chain at compile time //! //! # How It Works //! //! Each schema version is a concrete Rust struct. Migration steps convert between //! adjacent versions with full type safety — no raw JSON manipulation. //! //! The `migration_chain!` macro generates, for a chain of any length: //! 1. **Const assertions** — version sequence validated at compile time //! 2. **Type-checked dispatch** — compiler rejects mismatched type chains //! 3. **`MigrationChain` impl** — runtime upgrade/downgrade with version detection, `O(N)` //! in generated code size (one helper function per step, not one per historical version //! reachable from every other version) //! //! # Example //! //! ```rust //! use aimdb_data_contracts::{SchemaType, MigrationStep, MigrationChain, MigrationError}; //! use aimdb_data_contracts::migration_chain; //! use serde::{Deserialize, Serialize}; //! //! // v1 schema //! #[derive(Clone, Debug, Serialize, Deserialize)] //! struct SensorV1 { //! schema_version: u32, //! temp: f32, //! timestamp: u64, //! } //! impl SchemaType for SensorV1 { //! const NAME: &'static str = "sensor_v1"; //! const VERSION: u32 = 1; //! } //! //! // v2 schema (current) //! #[derive(Clone, Debug, Serialize, Deserialize)] //! struct Sensor { //! schema_version: u32, //! celsius: f32, //! timestamp: u64, //! } //! impl SchemaType for Sensor { //! const NAME: &'static str = "sensor"; //! const VERSION: u32 = 2; //! } //! //! // Migration step: v1 -> v2 //! struct SensorV1ToV2; //! impl MigrationStep for SensorV1ToV2 { //! type Older = SensorV1; //! type Newer = Sensor; //! const FROM_VERSION: u32 = 1; //! const TO_VERSION: u32 = 2; //! //! fn up(v1: SensorV1) -> Result { //! Ok(Sensor { schema_version: 2, celsius: v1.temp, timestamp: v1.timestamp }) //! } //! fn down(v2: Sensor) -> Result { //! Ok(SensorV1 { schema_version: 1, temp: v2.celsius, timestamp: v2.timestamp }) //! } //! } //! //! // Wire up the chain //! migration_chain! { //! type Current = Sensor; //! version_field = "schema_version"; //! steps { //! SensorV1ToV2: SensorV1 => Sensor, //! } //! } //! //! // Upgrade from v1 bytes //! let v1_json = r#"{"schema_version":1,"temp":22.5,"timestamp":100}"#; //! let sensor = Sensor::migrate_from_bytes(v1_json.as_bytes()).unwrap(); //! assert_eq!(sensor.celsius, 22.5); //! //! // Downgrade to v1 bytes //! let v1_bytes = sensor.migrate_to_version(1).unwrap(); //! let v1_roundtrip: serde_json::Value = serde_json::from_slice(&v1_bytes).unwrap(); //! assert_eq!(v1_roundtrip["temp"], 22.5); //! ``` use crate::SchemaType; /// Error returned when schema migration fails. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MigrationError { /// The source version is newer than this binary supports VersionTooNew { source: u32, current: u32 }, /// The target downgrade version is below the minimum supported VersionTooOld { target: u32, minimum: u32 }, /// Deserialization of a versioned payload failed DeserializationFailed(&'static str), /// Serialization during downgrade failed SerializationFailed(&'static str), /// A domain-specific conversion error in a MigrationStep ConversionFailed(&'static str), /// Payload is missing the version field MissingVersion, } impl core::fmt::Display for MigrationError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::VersionTooNew { source, current } => { write!( f, "source version {} is newer than current {}", source, current ) } Self::VersionTooOld { target, minimum } => { write!( f, "target version {} is below minimum supported {}", target, minimum ) } Self::DeserializationFailed(msg) => write!(f, "deserialization failed: {}", msg), Self::SerializationFailed(msg) => write!(f, "serialization failed: {}", msg), Self::ConversionFailed(msg) => write!(f, "conversion failed: {}", msg), Self::MissingVersion => write!(f, "payload missing version field"), } } } /// A single, typed, bidirectional migration step between two schema versions. /// /// Each step converts between two concrete Rust types with full type safety. /// The compiler enforces that `up()` and `down()` operate on the correct types. /// /// # Example /// /// ```rust /// # use aimdb_data_contracts::{MigrationError, MigrationStep}; /// # struct TemperatureV1 { schema_version: u32, temp: f64, timestamp: u64, unit: String } /// # struct TemperatureV2 { schema_version: u32, celsius: f64, timestamp: u64 } /// struct TemperatureV1ToV2; /// impl MigrationStep for TemperatureV1ToV2 { /// type Older = TemperatureV1; /// type Newer = TemperatureV2; /// const FROM_VERSION: u32 = 1; /// const TO_VERSION: u32 = 2; /// /// fn up(v1: TemperatureV1) -> Result { /// let celsius = match v1.unit.as_str() { /// "F" => (v1.temp - 32.0) * 5.0 / 9.0, /// "K" => v1.temp - 273.15, /// _ => v1.temp, /// }; /// Ok(TemperatureV2 { schema_version: 2, celsius, timestamp: v1.timestamp }) /// } /// fn down(v2: TemperatureV2) -> Result { /// Ok(TemperatureV1 { schema_version: 1, temp: v2.celsius, timestamp: v2.timestamp, unit: "C".into() }) /// } /// } /// ``` pub trait MigrationStep { /// The older schema type (input to `up`, output of `down`) type Older; /// The newer schema type (output of `up`, input to `down`) type Newer; /// The version number of the Older type const FROM_VERSION: u32; /// The version number of the Newer type const TO_VERSION: u32; /// Upgrade: convert from older to newer representation. fn up(older: Self::Older) -> Result; /// Downgrade: convert from newer to older representation. fn down(newer: Self::Newer) -> Result; } /// A complete, validated migration chain for a schema type. /// /// Generated by the `migration_chain!` macro. Provides runtime dispatch /// for upgrading from any historical version to the current version, /// and downgrading from the current version to any historical version. /// /// All chain validation (sequential versions, type chaining) happens /// at compile time via const assertions and type checking in the macro expansion. pub trait MigrationChain: SchemaType + serde::de::DeserializeOwned + serde::Serialize { /// The minimum version this chain can upgrade from. const MIN_VERSION: u32; /// Deserialize from bytes, auto-detecting version and upgrading to current. /// /// Reads the version field from the JSON payload and walks the migration /// chain upward to produce the current schema version. fn migrate_from_bytes(data: &[u8]) -> Result; /// Downgrade to a target version and serialize to bytes. /// /// Walks the migration chain downward from the current version to produce /// the serialized representation of an older schema version. fn migrate_to_version( &self, target_version: u32, ) -> Result, MigrationError>; } /// Compile-only proof that `migration_chain!` arity is unbounded — a 4-step /// chain (5 schema versions), not itself a test. Runtime correctness for /// chains beyond the historical 3-step ceiling is proven on host by /// `tests/migration_roundtrip.rs`; this module exists purely so the /// `thumbv7em-none-eabihf` check lane (which can't compile `tests/*.rs` — /// no `std` test harness on a bare-metal target) still exercises a >3-step /// chain. Lives inside this crate (not `tests/`), so it needs /// `extern crate self as aimdb_data_contracts;` (see `lib.rs`) for the /// macro's generated `::aimdb_data_contracts::...` absolute paths to /// resolve. #[allow(dead_code)] mod arity_check { use crate::{MigrationError, MigrationStep, SchemaType}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize)] struct V1 { schema_version: u32, n: u32, } impl SchemaType for V1 { const NAME: &'static str = "arity_check"; const VERSION: u32 = 1; } #[derive(Clone, Debug, Serialize, Deserialize)] struct V2 { schema_version: u32, n: u32, } impl SchemaType for V2 { const NAME: &'static str = "arity_check"; const VERSION: u32 = 2; } #[derive(Clone, Debug, Serialize, Deserialize)] struct V3 { schema_version: u32, n: u32, } impl SchemaType for V3 { const NAME: &'static str = "arity_check"; const VERSION: u32 = 3; } #[derive(Clone, Debug, Serialize, Deserialize)] struct V4 { schema_version: u32, n: u32, } impl SchemaType for V4 { const NAME: &'static str = "arity_check"; const VERSION: u32 = 4; } #[derive(Clone, Debug, Serialize, Deserialize)] struct V5 { schema_version: u32, n: u32, } impl SchemaType for V5 { const NAME: &'static str = "arity_check"; const VERSION: u32 = 5; } struct Step1; impl MigrationStep for Step1 { type Older = V1; type Newer = V2; const FROM_VERSION: u32 = 1; const TO_VERSION: u32 = 2; fn up(v: V1) -> Result { Ok(V2 { schema_version: 2, n: v.n, }) } fn down(v: V2) -> Result { Ok(V1 { schema_version: 1, n: v.n, }) } } struct Step2; impl MigrationStep for Step2 { type Older = V2; type Newer = V3; const FROM_VERSION: u32 = 2; const TO_VERSION: u32 = 3; fn up(v: V2) -> Result { Ok(V3 { schema_version: 3, n: v.n, }) } fn down(v: V3) -> Result { Ok(V2 { schema_version: 2, n: v.n, }) } } struct Step3; impl MigrationStep for Step3 { type Older = V3; type Newer = V4; const FROM_VERSION: u32 = 3; const TO_VERSION: u32 = 4; fn up(v: V3) -> Result { Ok(V4 { schema_version: 4, n: v.n, }) } fn down(v: V4) -> Result { Ok(V3 { schema_version: 3, n: v.n, }) } } struct Step4; impl MigrationStep for Step4 { type Older = V4; type Newer = V5; const FROM_VERSION: u32 = 4; const TO_VERSION: u32 = 5; fn up(v: V4) -> Result { Ok(V5 { schema_version: 5, n: v.n, }) } fn down(v: V5) -> Result { Ok(V4 { schema_version: 4, n: v.n, }) } } crate::migration_chain! { type Current = V5; version_field = "schema_version"; steps { Step1: V1 => V2, Step2: V2 => V3, Step3: V3 => V4, Step4: V4 => V5, } } }