version-migrate
A Rust library for explicit, type-safe schema versioning and migration.
Overview
Applications that persist data locally (e.g., session data, configuration) require a robust mechanism for managing changes to the data's schema over time. Ad-hoc solutions using serde(default) or Option<T> obscure migration logic, introduce technical debt, and lack reliability.
version-migrate provides an explicit, type-safe, and developer-friendly framework for schema versioning and migration, inspired by the design philosophy of serde.
Features
- Explicit: All schema changes and migration logic must be explicitly coded and testable
- Type-Safe: Leverage Rust's type system to ensure migration paths are complete at compile time
- Robust: Provides a safe and reliable path to migrate data from any old version to the latest domain model
- Separation of Concerns: The core domain model remains completely unaware of persistence layer versioning details
- Developer Experience:
serde-like derive macro (#[derive(Versioned)]) to minimize boilerplate - Format Flexibility: Load from any serde-compatible format (JSON, TOML, YAML, etc.)
- Flat Format Support: Both wrapped (
{"version":"..","data":{..}}) and flat ({"version":"..","field":..}) formats - Auto-Tag: Direct serialization with
serde_json::to_string()- noMigratorrequired for simple versioning - ConfigMigrator: ORM-like interface for partial updates in complex JSON without version concerns
- Vec Support: Migrate collections of versioned entities with
save_vecandload_vec - Hierarchical Structures: Support for nested versioned entities with root-level versioning
- Custom Serialization Keys: Customize field names (
version_key,data_key) with three-tier priority (Path > Migrator > Type) - Async Support: Async traits for migrations requiring I/O operations (database, API calls)
- File Storage with ACID: Atomic file operations with retry logic, format conversion (TOML/JSON), and automatic cleanup
- Platform-Agnostic Paths: Unified path management across Linux, macOS, Windows with customizable strategies (System/Xdg/CustomBase)
Installation
Add this to your Cargo.toml:
[]
= "0.1.0"
= { = "1.0", = ["derive"] }
= "1.0"
Quick Start
use ;
use ;
// Version 1.0.0
// Version 1.1.0
// Domain model (clean, version-agnostic)
// Migration from V1.0.0 to V1.1.0
// Conversion to domain model
Key Features
Save and Load
// Save versioned data to JSON
let task = TaskV1_0_0 ;
let json = migrator.save?;
// → {"version":"1.0.0","data":{"id":"1","title":"My Task"}}
// Load and automatically migrate to latest version
let task: TaskEntity = migrator.load?;
Auto-Tag: Direct Serialization with Version
For cases where you want to use standard serde_json::to_string() directly without going through the Migrator, you can enable the auto_tag option:
// Now you can use serde directly!
let task = Task ;
let json = to_string?;
// → {"version":"1.0.0","id":"1","title":"My Task"}
// Deserialization also works with version validation
let task: Task = from_str?;
Key features:
auto_tag = truegenerates customSerializeandDeserializeimplementations- Version field is automatically inserted during serialization
- Version is validated during deserialization (returns error if mismatch)
- Works with custom version keys:
#[versioned(version = "1.0.0", version_key = "schema_version", auto_tag = true)] - No need for
Migratorif you just want versioned serialization
Note: When auto_tag = true, you don't need #[derive(Serialize, Deserialize)] - the macro generates these implementations for you.
ConfigMigrator: Partial Updates Made Easy
For complex configuration files with multiple versioned entities, ConfigMigrator provides an ORM-like interface for querying and updating specific parts of the JSON without dealing with migration logic.
use ;
// Define your domain entity (version-agnostic) with queryable macro
// That's it! The macro automatically implements:
// - Queryable trait with ENTITY_NAME = "task"
// - No version needed - domain entities are version-agnostic
// Setup migrator with migration paths (as usual)
let mut migrator = new;
migrator.register?;
// config.json:
// {
// "app_name": "MyApp",
// "version": "1.0.0",
// "tasks": [
// {"version": "1.0.0", "id": "1", "title": "Old Task"},
// {"version": "2.0.0", "id": "2", "title": "New Task", "description": "Desc"}
// ]
// }
let config_json = read_to_string?;
let mut config = from?;
// Query tasks (automatically migrates all versions to TaskEntity)
let mut tasks: = config.query?;
// Work with domain entities (no version concerns!)
tasks.title = "Updated Task".to_string;
tasks.push;
// Update config (version is automatically determined from migration path)
config.update?;
// Save to file
write?;
// All tasks are now version 2.0.0!
Benefits:
- No version awareness needed: Work with domain entities (version-agnostic), not versioned DTOs
- Separation of concerns: Domain entities implement
Queryable, versioned DTOs implementVersioned - Partial updates: Only update specific keys in complex JSON structures
- Preserves other fields: Non-updated parts of the config remain unchanged
- Automatic migration: Old versions are transparently upgraded when queried
- Type-safe:
Queryabletrait ensures correct entity names at compile time - Zero boilerplate:
#[derive(Queryable)]macro eliminates manual trait implementation
Perfect for:
- Application configuration files with nested versioned data
- Session/state management with evolving schemas
- Multi-tenant systems where different tenants may have different data versions
Standalone Queryable Macro:
// Automatically implements:
Flat Format Support
In addition to the wrapped format, version-migrate supports flat format where the version field is at the same level as data fields. This is more common in general schema versioning scenarios.
// Save in flat format
let task = TaskV1_0_0 ;
let json = migrator.save_flat?;
// → {"version":"1.0.0","id":"1","title":"My Task"}
// Load from flat format
let task: TaskEntity = migrator.load_flat?;
Format Comparison:
// Wrapped format (for DB/storage systems)
save →
load → Extracts from "data" field
// Flat format (for general schema versioning)
save_flat →
load_flat → Version field at same level as data
Vec Support:
// Save and load collections in flat format
let tasks = vec!;
let json = migrator.save_vec_flat?;
// → [{"version":"1.0.0","id":"1",...}, {"version":"1.0.0","id":"2",...}]
let tasks: = migrator.load_vec_flat?;
Runtime Override:
Flat format also supports the same three-tier priority system for customizing version keys:
// Custom version key in flat format
let path = define
.with_keys // data_key not used in flat format
.
.;
let json = r#"{"schema_version":"1.0.0","id":"1","title":"Task"}"#;
let task: TaskEntity = migrator.load_flat?;
Multiple Format Support
The load_from method supports loading from any serde-compatible format (TOML, YAML, etc.):
// Load from TOML
let toml_str = r#"
version = "1.0.0"
[data]
id = "task-1"
title = "My Task"
"#;
let toml_value: Value = from_str?;
let task: TaskEntity = migrator.load_from?;
// Load from YAML
let yaml_str = r#"
version: "1.0.0"
data:
id: "task-1"
title: "My Task"
"#;
let yaml_value: Value = from_str?;
let task: TaskEntity = migrator.load_from?;
// JSON still works with the convenient load() method
let json = r#"{"version":"1.0.0","data":{"id":"task-1","title":"My Task"}}"#;
let task: TaskEntity = migrator.load?;
Automatic Migration
The migrator automatically applies all necessary migration steps:
// Even if data is V1.0.0, it will migrate through V1.1.0 → V1.2.0 → ... → Latest
let old_json = r#"{"version":"1.0.0","data":{...}}"#;
let latest: TaskEntity = migrator.load?;
Type-Safe Builder Pattern
The builder pattern ensures migration paths are complete at compile time:
define
. // Starting version
. // Must implement MigratesTo<V2> for V1
. // Must implement MigratesTo<V3> for V2
.; // Must implement IntoDomain<Domain> for V3
Working with Collections (Vec)
Migrate multiple entities at once using save_vec and load_vec:
// Save multiple versioned entities
let tasks = vec!;
let json = migrator.save_vec?;
// → [{"version":"1.0.0","data":{"id":"1",...}}, ...]
// Load and migrate all entities
let domains: = migrator.load_vec?;
The load_vec_from method also supports any serde-compatible format:
// Load from TOML array
let toml_array: = /* ... */;
let domains: = migrator.load_vec_from?;
// Load from YAML array
let yaml_array: = /* ... */;
let domains: = migrator.load_vec_from?;
Hierarchical Structures
For complex configurations with nested versioned entities, define migrations at the root level:
// Version 1.0.0 - Nested structure
// Version 2.0.0 - All nested entities migrate together
// Migrate the entire hierarchy
Design Philosophy:
- Root-level versioning ensures consistency across nested structures
- Each version has explicit types (ConfigV1, ConfigV2, etc.)
- All nested entities migrate together as a unit
- Migration logic is explicit and testable
This approach differs from ProtoBuf's "append-only" style but enables:
- Schema refactoring and cleanup
- Type-safe nested migrations
- Clear version history in code
Custom Serialization Keys
For integrating with existing systems that use different field names (e.g., schema_version instead of version):
let migrator = new;
let task = Task ;
let json = migrator.save?;
// → {"schema_version":"1.0.0","payload":{"id":"1","title":"Task"}}
Use cases:
- Migrating existing data with custom field names
- Integrating with external APIs that use specific naming conventions
- Supporting multiple serialization formats with different requirements
Default keys:
version_key: defaults to"version"data_key: defaults to"data"
Runtime Key Override
Beyond compile-time customization, you can override serialization keys at runtime with a three-tier priority system:
Priority (highest to lowest):
- Path-level (via
with_keys()) - Migrator-level (via
builder()) - Type-level (via
#[versioned]macro)
Migrator-Level Defaults
Set default keys for all entities using Migrator::builder():
let migrator = builder
.default_version_key
.default_data_key
.build;
// All entities will use these keys unless overridden
let path = define
.
.;
migrator.register?;
// Load with migrator-level keys
let json = r#"{"schema_version":"1.0.0","payload":{"id":"1","title":"Task"}}"#;
let task: TaskDomain = migrator.load?;
Path-Level Override
Override keys for specific migration paths using with_keys():
let path = define
.with_keys
.
.
.;
let mut migrator = builder
.default_version_key
.default_data_key
.build;
migrator.register?;
// Path-level keys take precedence over migrator defaults
let json = r#"{"custom_ver":"1.0.0","custom_data":{"id":"1","title":"Task"}}"#;
let task: TaskDomain = migrator.load?;
Priority Example
// Type level: version_key = "type_version"
// Migrator level overrides type level
let mut migrator = builder
.default_version_key // Takes priority
.build;
// Path level overrides migrator level
let path = define
.with_keys // Highest priority
.
.;
Use cases:
- Integrating multiple external systems with different naming conventions
- Supporting legacy data formats without changing type definitions
- Per-entity customization in multi-tenant systems
Async Support
For migrations requiring I/O operations (database queries, API calls), use async traits:
use ;
Migration Path Validation
Migration paths are automatically validated when registered:
let path = define
.
.
.;
let mut migrator = new;
migrator.register?; // Validates before registering
Validation checks:
- No circular paths: Prevents version A → B → A loops
- Semver ordering: Ensures versions increase (1.0.0 → 1.1.0 → 2.0.0)
Comprehensive Error Handling
All operations return Result<T, MigrationError>:
match migrator.load
File Storage with ACID Guarantees
FileStorage provides atomic file operations with ACID guarantees for persistent configuration:
use ;
// Configure storage strategy
let strategy = default
.with_format // or Json
.with_retry_count
.with_load_behavior;
// Create storage (automatically loads from file if exists)
let mut storage = new?;
// Query and update with automatic migration
let tasks: = storage.query?;
storage.update_and_save?;
Features:
- Atomicity: Temporary file + atomic rename ensures all-or-nothing updates
- Retry Logic: Configurable retry count for rename operations (default: 3)
- Format Support: TOML or JSON with automatic conversion
- Load Strategies: Create empty config if missing, or return error
- Cleanup: Automatic cleanup of temporary files (best effort)
Platform-Agnostic Path Management
AppPaths provides unified path resolution across platforms:
use ;
// Use OS-standard directories (default)
let paths = new;
// Linux: ~/.config/myapp
// macOS: ~/Library/Application Support/myapp
// Windows: %APPDATA%\myapp
// Force XDG on all platforms (for consistency)
let paths = new
.config_strategy
.data_strategy;
// All platforms: ~/.config/myapp, ~/.local/share/myapp
// Use custom base (useful for testing)
let paths = new
.config_strategy;
Path Methods:
// Get directory paths (creates if missing)
let config_dir = paths.config_dir?; // ~/.config/myapp
let data_dir = paths.data_dir?; // ~/.local/share/myapp
// Get file paths (creates parent directory)
let config_file = paths.config_file?;
let cache_file = paths.data_file?;
Complete Example: Persistent Configuration
Combining FileStorage and AppPaths for production use:
use ;
// Define your entity with Queryable
Testing with Temporary Directories:
use TempDir;
Important Notes:
- Production: Use
PathStrategy::SystemorXdgfor real user directories - Testing: Use
PathStrategy::CustomBasewithtempfile::TempDirto avoid polluting home directory - TOML vs JSON: TOML is more human-readable; JSON is more compact
Architecture
The library is split into two crates:
version-migrate: Core library with traits,Migrator, and error typesversion-migrate-macro: Procedural macro for derivingVersionedtrait
This mirrors the structure of popular libraries like serde.
Documentation
For detailed documentation, see:
Development
Running Tests
Running Checks
Building Documentation
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Acknowledgments
This library is inspired by:
serde- For its derive macro pattern and API design philosophy- Database migration tools - For the concept of explicit, versioned migrations