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:
teernisse
2026-02-26 10:26:08 -05:00
parent c069e03714
commit 61a068ad99
2 changed files with 394 additions and 0 deletions

View 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);
}
}

View File

@@ -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;