feat: add schema migration utilities for versioned state files
Implements MigrationRegistry for managing versioned JSON file migrations: - Version tracking for gitlab_bead_map.json, state.json, settings.json - Sequential migration execution (v1→v2→v3) - Error handling for future versions, missing versions, failed migrations - Factory functions for each file type's registry Includes 12 tests covering migration chains, error cases, and idempotency. bd-1jf Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
393
src-tauri/src/data/migration.rs
Normal file
393
src-tauri/src/data/migration.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
//! Schema migration utilities for MC state files.
|
||||
//!
|
||||
//! Handles versioned JSON files with automatic migration:
|
||||
//! - gitlab_bead_map.json (schema_version: 1)
|
||||
//! - state.json (schema_version: 1)
|
||||
//! - settings.json (schema_version: 1)
|
||||
//!
|
||||
//! Decision log (jsonl) is append-only and self-describing, so no versioning needed.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Current schema versions for each file type.
|
||||
pub mod versions {
|
||||
/// Current version for gitlab_bead_map.json
|
||||
pub const GITLAB_BEAD_MAP: u32 = 1;
|
||||
/// Current version for state.json
|
||||
pub const FRONTEND_STATE: u32 = 1;
|
||||
/// Current version for settings.json
|
||||
pub const SETTINGS: u32 = 1;
|
||||
}
|
||||
|
||||
/// Error type for migration failures.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MigrationError {
|
||||
/// The source version is newer than what we support
|
||||
FutureVersion { file_version: u32, max_supported: u32 },
|
||||
/// A migration function failed
|
||||
MigrationFailed { from: u32, to: u32, reason: String },
|
||||
/// Missing schema_version field
|
||||
MissingVersion,
|
||||
/// Invalid schema_version field type
|
||||
InvalidVersionType,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MigrationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MigrationError::FutureVersion { file_version, max_supported } => {
|
||||
write!(
|
||||
f,
|
||||
"File has schema version {} but we only support up to {}",
|
||||
file_version, max_supported
|
||||
)
|
||||
}
|
||||
MigrationError::MigrationFailed { from, to, reason } => {
|
||||
write!(f, "Migration from v{} to v{} failed: {}", from, to, reason)
|
||||
}
|
||||
MigrationError::MissingVersion => {
|
||||
write!(f, "File is missing schema_version field")
|
||||
}
|
||||
MigrationError::InvalidVersionType => {
|
||||
write!(f, "schema_version field is not a valid integer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MigrationError {}
|
||||
|
||||
/// A migration function that transforms data from one version to the next.
|
||||
///
|
||||
/// The function receives the data (with schema_version already updated)
|
||||
/// and returns the migrated data or an error message.
|
||||
pub type MigrationFn = fn(Value) -> Result<Value, String>;
|
||||
|
||||
/// Registry of migrations for a specific file type.
|
||||
pub struct MigrationRegistry {
|
||||
/// Current schema version for this file type
|
||||
current_version: u32,
|
||||
/// Migrations indexed by (from_version, to_version)
|
||||
migrations: HashMap<(u32, u32), MigrationFn>,
|
||||
}
|
||||
|
||||
impl MigrationRegistry {
|
||||
/// Create a new registry with the given current version.
|
||||
pub fn new(current_version: u32) -> Self {
|
||||
Self {
|
||||
current_version,
|
||||
migrations: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a migration from one version to the next.
|
||||
///
|
||||
/// Migrations should only go up one version at a time (v1→v2, v2→v3, etc.)
|
||||
pub fn register(&mut self, from: u32, to: u32, migration: MigrationFn) {
|
||||
self.migrations.insert((from, to), migration);
|
||||
}
|
||||
|
||||
/// Get the current schema version.
|
||||
pub fn current_version(&self) -> u32 {
|
||||
self.current_version
|
||||
}
|
||||
|
||||
/// Migrate data to the current version.
|
||||
///
|
||||
/// Returns the migrated data and whether any migrations were run.
|
||||
pub fn migrate(&self, mut data: Value) -> Result<(Value, bool), MigrationError> {
|
||||
let file_version = extract_version(&data)?;
|
||||
|
||||
// Check for future version
|
||||
if file_version > self.current_version {
|
||||
return Err(MigrationError::FutureVersion {
|
||||
file_version,
|
||||
max_supported: self.current_version,
|
||||
});
|
||||
}
|
||||
|
||||
// No migration needed
|
||||
if file_version == self.current_version {
|
||||
return Ok((data, false));
|
||||
}
|
||||
|
||||
// Run migrations in sequence
|
||||
let mut version = file_version;
|
||||
while version < self.current_version {
|
||||
let next_version = version + 1;
|
||||
|
||||
// Update version in data first
|
||||
if let Some(obj) = data.as_object_mut() {
|
||||
obj.insert("schema_version".to_string(), Value::Number(next_version.into()));
|
||||
}
|
||||
|
||||
// Find and run migration
|
||||
if let Some(migration) = self.migrations.get(&(version, next_version)) {
|
||||
data = migration(data).map_err(|reason| MigrationError::MigrationFailed {
|
||||
from: version,
|
||||
to: next_version,
|
||||
reason,
|
||||
})?;
|
||||
}
|
||||
// If no migration registered, just bump the version (noop migration)
|
||||
|
||||
version = next_version;
|
||||
}
|
||||
|
||||
Ok((data, true))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the schema_version from a JSON value.
|
||||
fn extract_version(data: &Value) -> Result<u32, MigrationError> {
|
||||
let version = data
|
||||
.get("schema_version")
|
||||
.ok_or(MigrationError::MissingVersion)?;
|
||||
|
||||
version
|
||||
.as_u64()
|
||||
.map(|v| v as u32)
|
||||
.ok_or(MigrationError::InvalidVersionType)
|
||||
}
|
||||
|
||||
/// Create a registry for gitlab_bead_map.json migrations.
|
||||
pub fn gitlab_bead_map_registry() -> MigrationRegistry {
|
||||
let registry = MigrationRegistry::new(versions::GITLAB_BEAD_MAP);
|
||||
|
||||
// Register migrations here as the schema evolves:
|
||||
// registry.register(1, 2, |data| { ... });
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
/// Create a registry for state.json migrations.
|
||||
pub fn frontend_state_registry() -> MigrationRegistry {
|
||||
let registry = MigrationRegistry::new(versions::FRONTEND_STATE);
|
||||
|
||||
// Register migrations here as the schema evolves:
|
||||
// registry.register(1, 2, |data| { ... });
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
/// Create a registry for settings.json migrations.
|
||||
pub fn settings_registry() -> MigrationRegistry {
|
||||
let registry = MigrationRegistry::new(versions::SETTINGS);
|
||||
|
||||
// Register migrations here as the schema evolves:
|
||||
// registry.register(1, 2, |data| { ... });
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_no_migration_needed_for_current_version() {
|
||||
let registry = MigrationRegistry::new(1);
|
||||
let data = json!({
|
||||
"schema_version": 1,
|
||||
"foo": "bar"
|
||||
});
|
||||
|
||||
let (result, migrated) = registry.migrate(data.clone()).unwrap();
|
||||
|
||||
assert!(!migrated);
|
||||
assert_eq!(result, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_runs_for_older_version() {
|
||||
let mut registry = MigrationRegistry::new(2);
|
||||
registry.register(1, 2, |mut data| {
|
||||
// Add a new field in v2
|
||||
if let Some(obj) = data.as_object_mut() {
|
||||
obj.insert("new_field".to_string(), Value::String("added".to_string()));
|
||||
}
|
||||
Ok(data)
|
||||
});
|
||||
|
||||
let data = json!({
|
||||
"schema_version": 1,
|
||||
"foo": "bar"
|
||||
});
|
||||
|
||||
let (result, migrated) = registry.migrate(data).unwrap();
|
||||
|
||||
assert!(migrated);
|
||||
assert_eq!(result["schema_version"], 2);
|
||||
assert_eq!(result["new_field"], "added");
|
||||
assert_eq!(result["foo"], "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_chain_v1_to_v3() {
|
||||
let mut registry = MigrationRegistry::new(3);
|
||||
|
||||
// v1→v2: add field_a
|
||||
registry.register(1, 2, |mut data| {
|
||||
if let Some(obj) = data.as_object_mut() {
|
||||
obj.insert("field_a".to_string(), Value::String("from_v2".to_string()));
|
||||
}
|
||||
Ok(data)
|
||||
});
|
||||
|
||||
// v2→v3: add field_b
|
||||
registry.register(2, 3, |mut data| {
|
||||
if let Some(obj) = data.as_object_mut() {
|
||||
obj.insert("field_b".to_string(), Value::String("from_v3".to_string()));
|
||||
}
|
||||
Ok(data)
|
||||
});
|
||||
|
||||
let data = json!({
|
||||
"schema_version": 1,
|
||||
"original": "data"
|
||||
});
|
||||
|
||||
let (result, migrated) = registry.migrate(data).unwrap();
|
||||
|
||||
assert!(migrated);
|
||||
assert_eq!(result["schema_version"], 3);
|
||||
assert_eq!(result["field_a"], "from_v2");
|
||||
assert_eq!(result["field_b"], "from_v3");
|
||||
assert_eq!(result["original"], "data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_future_version_returns_error() {
|
||||
let registry = MigrationRegistry::new(2);
|
||||
let data = json!({
|
||||
"schema_version": 5,
|
||||
"foo": "bar"
|
||||
});
|
||||
|
||||
let result = registry.migrate(data);
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
MigrationError::FutureVersion { file_version, max_supported } => {
|
||||
assert_eq!(file_version, 5);
|
||||
assert_eq!(max_supported, 2);
|
||||
}
|
||||
_ => panic!("Expected FutureVersion error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_failure_returns_error() {
|
||||
let mut registry = MigrationRegistry::new(2);
|
||||
registry.register(1, 2, |_data| Err("Something went wrong".to_string()));
|
||||
|
||||
let data = json!({
|
||||
"schema_version": 1,
|
||||
"foo": "bar"
|
||||
});
|
||||
|
||||
let result = registry.migrate(data);
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
MigrationError::MigrationFailed { from, to, reason } => {
|
||||
assert_eq!(from, 1);
|
||||
assert_eq!(to, 2);
|
||||
assert_eq!(reason, "Something went wrong");
|
||||
}
|
||||
_ => panic!("Expected MigrationFailed error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_version_returns_error() {
|
||||
let registry = MigrationRegistry::new(1);
|
||||
let data = json!({
|
||||
"foo": "bar"
|
||||
});
|
||||
|
||||
let result = registry.migrate(data);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), MigrationError::MissingVersion));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_version_type_returns_error() {
|
||||
let registry = MigrationRegistry::new(1);
|
||||
let data = json!({
|
||||
"schema_version": "not a number",
|
||||
"foo": "bar"
|
||||
});
|
||||
|
||||
let result = registry.migrate(data);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), MigrationError::InvalidVersionType));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_noop_migration_bumps_version() {
|
||||
// If no migration is registered, version should still bump
|
||||
let registry = MigrationRegistry::new(3);
|
||||
// No migrations registered
|
||||
|
||||
let data = json!({
|
||||
"schema_version": 1,
|
||||
"foo": "bar"
|
||||
});
|
||||
|
||||
let (result, migrated) = registry.migrate(data).unwrap();
|
||||
|
||||
assert!(migrated);
|
||||
assert_eq!(result["schema_version"], 3);
|
||||
assert_eq!(result["foo"], "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gitlab_bead_map_registry_current_version() {
|
||||
let registry = gitlab_bead_map_registry();
|
||||
assert_eq!(registry.current_version(), versions::GITLAB_BEAD_MAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_frontend_state_registry_current_version() {
|
||||
let registry = frontend_state_registry();
|
||||
assert_eq!(registry.current_version(), versions::FRONTEND_STATE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_registry_current_version() {
|
||||
let registry = settings_registry();
|
||||
assert_eq!(registry.current_version(), versions::SETTINGS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrations_are_idempotent() {
|
||||
// Running the same migration twice should produce the same result
|
||||
let mut registry = MigrationRegistry::new(2);
|
||||
registry.register(1, 2, |mut data| {
|
||||
if let Some(obj) = data.as_object_mut() {
|
||||
// Only add if not already present (idempotent)
|
||||
if !obj.contains_key("migrated") {
|
||||
obj.insert("migrated".to_string(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
Ok(data)
|
||||
});
|
||||
|
||||
let data = json!({
|
||||
"schema_version": 1,
|
||||
"foo": "bar"
|
||||
});
|
||||
|
||||
let (result1, _) = registry.migrate(data.clone()).unwrap();
|
||||
let (result2, migrated2) = registry.migrate(result1.clone()).unwrap();
|
||||
|
||||
// Second run should detect it's already at current version
|
||||
assert!(!migrated2);
|
||||
assert_eq!(result1, result2);
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,5 @@ pub mod beads;
|
||||
pub mod bridge;
|
||||
pub mod bv;
|
||||
pub mod lore;
|
||||
pub mod migration;
|
||||
pub mod state;
|
||||
|
||||
Reference in New Issue
Block a user