From 61a068ad995ef43c1fbd07f2c57df63ec5dd81b9 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 10:26:08 -0500 Subject: [PATCH] feat: add schema migration utilities for versioned state files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src-tauri/src/data/migration.rs | 393 ++++++++++++++++++++++++++++++++ src-tauri/src/data/mod.rs | 1 + 2 files changed, 394 insertions(+) create mode 100644 src-tauri/src/data/migration.rs diff --git a/src-tauri/src/data/migration.rs b/src-tauri/src/data/migration.rs new file mode 100644 index 0000000..0d98a4d --- /dev/null +++ b/src-tauri/src/data/migration.rs @@ -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; + +/// 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 { + 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); + } +} diff --git a/src-tauri/src/data/mod.rs b/src-tauri/src/data/mod.rs index 67953cf..15f9b85 100644 --- a/src-tauri/src/data/mod.rs +++ b/src-tauri/src/data/mod.rs @@ -9,4 +9,5 @@ pub mod beads; pub mod bridge; pub mod bv; pub mod lore; +pub mod migration; pub mod state;