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 bridge;
|
||||||
pub mod bv;
|
pub mod bv;
|
||||||
pub mod lore;
|
pub mod lore;
|
||||||
|
pub mod migration;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
Reference in New Issue
Block a user