//! Migration framework scaffold (Iteration 6, ADR-0015 §9 / //! requirement F3). //! //! The shape lands in v1 even though no migrator is //! registered: the cost is small, the wiring is non-trivial, //! and shipping the framework now lets the *first* real //! migrator (v1 → v2, when that lands) be a tightly scoped //! one-file change rather than "design migrations + write a //! migrator + integrate." //! //! Public surface: //! //! - [`MigratorRegistry`] — ordered list of `MigrateFn`s, one //! per source version. Tests inject their own registries; the //! production-default registry is empty. //! - [`migrate_to_latest`] — given a YAML body and a registry, //! detect the source version, run each migrator in //! sequence, and return the upgraded body. Writes the //! pre-migration body to `project.yaml.v.bak` inside the //! project (a recovery aid; the .gitignore template excludes //! `project.yaml.v*.bak` so backups don't leak into git). //! //! What this does NOT do: //! //! - It does not write `project.yaml` itself. The runtime is //! responsible for atomically writing the upgraded body back. //! Keeping the migration step separate from the write step //! makes the order of operations explicit at the call site //! and trivially testable: pass in a body, get back a body. //! - It does not parse anything beyond the leading `version:` //! line. Full schema parsing is `yaml::parse_schema`'s job //! and runs *after* migration so the parser only ever sees //! the latest format. use std::path::Path; use serde::Deserialize; /// A pure migrator: takes a YAML body at version `N` and /// returns the same project at version `N + 1`. /// /// Migrators must not perform I/O. The framework is /// responsible for the .bak copy and the write-back; the /// migrator's job is purely the format transformation. pub type MigrateFn = fn(&str) -> Result; /// The newest `project.yaml` format version this build writes and reads. /// /// Must equal `MigratorRegistry::production().latest_version()` (asserted /// in tests) and is what the YAML serializer stamps and the parser /// accepts. Bumped 1 → 2 by **ADR-0005 Amendment 2** (drop `blob`). pub const CURRENT_SCHEMA_VERSION: u32 = 2; /// v1 → v2 migrator (ADR-0005 Amendment 2). `blob` was dropped from the /// type vocabulary, so any `type: blob` column is rewritten to /// `type: text` (the chosen conversion target — see the ADR). A pure, /// line-oriented text transform: only column-definition lines (the /// `- { name: …, type: … }` shape the serializer emits) are touched, so a /// `blob` substring inside a CHECK expression or a default value is left /// alone. Also bumps the `version:` field to 2 (the framework asserts the /// advertised version, so this must stay in step). fn migrate_v1_to_v2(body: &str) -> Result { let mut out = String::with_capacity(body.len() + 1); for line in body.split_inclusive('\n') { if line.trim_end() == "version: 1" { out.push_str(&line.replacen("version: 1", "version: 2", 1)); } else if line.contains("{ name:") { out.push_str(&line.replace(", type: blob", ", type: text")); } else { out.push_str(line); } } Ok(out) } /// Ordered list of migrators. `migrators[i]` runs from /// version `i + 1` to version `i + 2` (so index 0 is v1→v2, /// index 1 is v2→v3, etc.). /// /// `latest_version()` is `1 + migrators.len()`. In v1 the /// list is empty and `latest_version()` is `1`. #[derive(Debug, Clone)] pub struct MigratorRegistry { pub migrators: Vec, } impl MigratorRegistry { /// Production-default registry. Migrators are listed in /// source-version order (index 0 = v1→v2, …). #[must_use] pub fn production() -> Self { Self { migrators: vec![migrate_v1_to_v2], } } /// The newest schema version this build understands. #[must_use] pub const fn latest_version(&self) -> u32 { 1u32.saturating_add(self.migrators.len() as u32) } } impl Default for MigratorRegistry { fn default() -> Self { Self::production() } } #[derive(Debug)] pub enum MigrateError { VersionParse(String), NewerThanSupported { file: u32, latest: u32, }, NoMigratorForVersion(u32), StepFailed { from: u32, to: u32, source: Box, }, BadOutput(String), Io { path: std::path::PathBuf, source: std::io::Error, }, } impl std::fmt::Display for MigrateError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::VersionParse(detail) => f.write_str(&crate::t!( "persistence.migrate.version_parse", detail = detail, )), Self::NewerThanSupported { file, latest } => f.write_str(&crate::t!( "persistence.migrate.newer_than_supported", file = file, latest = latest, )), Self::NoMigratorForVersion(v) => { f.write_str(&crate::t!("persistence.migrate.no_migrator", version = v,)) } Self::StepFailed { from, to, source } => f.write_str(&crate::t!( "persistence.migrate.step_failed", from = from, to = to, source = source, )), Self::BadOutput(detail) => f.write_str(&crate::t!( "persistence.migrate.bad_output", detail = detail, )), Self::Io { path, source } => f.write_str(&crate::t!( "persistence.migrate.io", path = path.display(), source = source, )), } } } impl std::error::Error for MigrateError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::StepFailed { source, .. } => Some(source.as_ref()), Self::Io { source, .. } => Some(source), _ => None, } } } /// Result of running [`migrate_to_latest`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MigrationOutcome { /// The upgraded body. When no migration was needed this /// is identical to the input body. pub body: String, /// Source version found in the input. `None` if the /// input parsed but its version equals `latest_version` /// (no migration ran); `Some(N)` if a migration ran from /// `N` to `latest_version`. pub migrated_from: Option, } /// Whether `body` declares at least one `type: blob` column. /// /// The runtime uses this as the signal to force a `.db` rebuild after the /// v1→v2 migration: the migration rewrites the YAML, but the derived /// `.db` keeps a `STRICT … BLOB` engine column + `"blob"` metadata that /// load otherwise uses as-is (ADR-0005 Amendment 2). Scoped to /// column-definition lines, mirroring [`migrate_v1_to_v2`], so a `blob` /// substring inside a CHECK expression or default doesn't trip it. #[must_use] pub fn body_declares_blob_column(body: &str) -> bool { body.lines() .any(|l| l.contains("{ name:") && l.contains(", type: blob")) } /// Detect the version of `body` and migrate it to the /// registry's `latest_version()`. /// /// If the body is already at the latest version, returns the /// body unchanged with `migrated_from = None`. Otherwise: /// /// 1. Writes `/project.yaml.v.bak` with the /// original body (so the recovery aid is in place before /// we start mutating). /// 2. Runs each registered migrator in sequence from /// `file_version` to `latest_version`. /// 3. Returns the upgraded body for the caller to write back. /// /// A future-version body (file_version > latest_version) /// errors out — older builds shouldn't try to interpret /// newer formats they don't understand. pub fn migrate_to_latest( body: &str, registry: &MigratorRegistry, project_path: &Path, ) -> Result { let file_version = read_version(body)?; let latest = registry.latest_version(); if file_version == latest { return Ok(MigrationOutcome { body: body.to_string(), migrated_from: None, }); } if file_version > latest { return Err(MigrateError::NewerThanSupported { file: file_version, latest, }); } // Write the .bak before any transformation runs so a // mid-migration crash leaves the original recoverable. let bak_path = project_path.join(format!( "{}.v{}.bak", crate::project::PROJECT_YAML, file_version )); std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io { path: bak_path.clone(), source, })?; let mut current_body = body.to_string(); for v in file_version..latest { let idx = (v - 1) as usize; let migrator = registry .migrators .get(idx) .ok_or(MigrateError::NoMigratorForVersion(v))?; let next_body = migrator(¤t_body).map_err(|e| MigrateError::StepFailed { from: v, to: v + 1, source: Box::new(e), })?; // Sanity: the new body must declare the next version. // If a migrator forgets to bump, we'd loop endlessly // through the chain — catch it here. let advertised = read_version(&next_body).map_err(|e| MigrateError::BadOutput(e.to_string()))?; if advertised != v + 1 { return Err(MigrateError::BadOutput(format!( "v{v}→v{} migrator left version field at {advertised}", v + 1, ))); } current_body = next_body; } Ok(MigrationOutcome { body: current_body, migrated_from: Some(file_version), }) } /// Ensure the `project.yaml` at `project_path` is migrated /// to the registry's latest version, writing the upgraded /// body back to disk if a migration ran. /// /// Convenience wrapper that pairs [`migrate_to_latest`] with /// the read/write IO. Used by the runtime on every project /// open (before the rebuild path or DB-existence check /// touches anything). /// /// A missing `project.yaml` is `Ok(MigrationOutcome { body: /// "", migrated_from: None })` — a brand-new project that /// the skeleton hasn't filled in yet falls into this branch /// and is left alone. pub fn ensure_project_yaml_migrated( project_path: &Path, registry: &MigratorRegistry, ) -> Result { let yaml_path = project_path.join(crate::project::PROJECT_YAML); let body = match std::fs::read_to_string(&yaml_path) { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(MigrationOutcome { body: String::new(), migrated_from: None, }); } Err(source) => { return Err(MigrateError::Io { path: yaml_path, source, }); } }; let outcome = migrate_to_latest(&body, registry, project_path)?; if outcome.migrated_from.is_some() { std::fs::write(&yaml_path, &outcome.body).map_err(|source| MigrateError::Io { path: yaml_path, source, })?; } Ok(outcome) } /// Extract just the `version:` field from a YAML body, /// without parsing the rest of the document. fn read_version(body: &str) -> Result { #[derive(Deserialize)] struct VersionOnly { version: u32, } let v: VersionOnly = serde_norway::from_str(body).map_err(|e| MigrateError::VersionParse(e.to_string()))?; Ok(v.version) } #[cfg(test)] mod tests { use super::*; fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") } fn v1_body() -> String { "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n" .to_string() } /// A project at the current (latest) version — nothing to migrate. fn v2_body() -> String { "version: 2\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n" .to_string() } #[test] fn production_registry_latest_version_matches_current_schema_version() { let r = MigratorRegistry::production(); assert_eq!(r.latest_version(), CURRENT_SCHEMA_VERSION); assert_eq!(r.latest_version(), 2); } #[test] fn no_migration_runs_when_body_already_latest() { let tmp = tempdir(); let outcome = migrate_to_latest(&v2_body(), &MigratorRegistry::production(), tmp.path()).unwrap(); assert_eq!(outcome.body, v2_body()); assert_eq!(outcome.migrated_from, None); // No .bak written when nothing migrated. assert!( !tmp.path().join("project.yaml.v2.bak").exists(), "no .bak when no migration" ); } /// ADR-0005 Amendment 2: the real production v1→v2 migrator rewrites a /// `blob` column to `text` and bumps the version, leaving everything /// else (incl. a column literally named `blob`, and a `blob` substring /// inside a CHECK) untouched. #[test] fn production_v1_to_v2_converts_blob_columns_to_text() { let tmp = tempdir(); let body = concat!( "version: 1\n", "project:\n created_at: x\n", "tables:\n", " - name: Files\n", " primary_key: [id]\n", " columns:\n", " - { name: id, type: serial }\n", " - { name: payload, type: blob }\n", " - { name: blob, type: text }\n", " check_constraints:\n", " - \"note <> 'type: blob'\"\n", "relationships: []\n", ); let outcome = migrate_to_latest(body, &MigratorRegistry::production(), tmp.path()).unwrap(); assert_eq!(outcome.migrated_from, Some(1)); assert!(outcome.body.contains("version: 2")); // The blob column became text. assert!( outcome.body.contains("{ name: payload, type: text }"), "blob column converted: {}", outcome.body ); // A column NAMED blob is untouched; the CHECK string is untouched. assert!(outcome.body.contains("{ name: blob, type: text }")); assert!(outcome.body.contains("note <> 'type: blob'")); assert!(!outcome.body.contains("type: blob }")); } #[test] fn newer_than_supported_errors() { let body = "version: 99\nproject:\n created_at: x\n"; let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp")) .expect_err("must reject"); assert!( matches!( err, MigrateError::NewerThanSupported { file: 99, latest: 2 } ), "got: {err:?}", ); } #[test] fn malformed_version_errors() { let body = "tables: []\n"; let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp")) .expect_err("must reject"); assert!(matches!(err, MigrateError::VersionParse(_)), "got: {err:?}"); } // --- Exercise the framework with a fake v1→v2 migrator // so we know the chain runs even without a real one. --- fn fake_v1_to_v2(body: &str) -> Result { // Trivial transformation: bump the version number in // place. Exercises the registry plumbing without // committing to any real schema change. Ok(body.replace("version: 1", "version: 2")) } fn registry_with_v1_to_v2() -> MigratorRegistry { MigratorRegistry { migrators: vec![fake_v1_to_v2 as MigrateFn], } } #[test] fn registry_with_one_migrator_advertises_latest_version_2() { let r = registry_with_v1_to_v2(); assert_eq!(r.latest_version(), 2); } #[test] fn migrate_runs_chain_and_writes_bak() { let tmp = tempdir(); let outcome = migrate_to_latest(&v1_body(), ®istry_with_v1_to_v2(), tmp.path()).unwrap(); assert_eq!(outcome.migrated_from, Some(1)); assert!(outcome.body.contains("version: 2")); let bak = tmp.path().join("project.yaml.v1.bak"); assert!(bak.exists(), "expected .v1.bak to be written"); let bak_body = std::fs::read_to_string(&bak).unwrap(); assert!(bak_body.contains("version: 1")); } #[test] fn migrator_that_forgets_to_bump_version_is_caught() { let bad: MigrateFn = |body| Ok(body.to_string()); // no change let registry = MigratorRegistry { migrators: vec![bad], }; let tmp = tempdir(); let err = migrate_to_latest(&v1_body(), ®istry, tmp.path()).expect_err("must fail"); assert!(matches!(err, MigrateError::BadOutput(_)), "got: {err:?}"); } #[test] fn ensure_yaml_migrated_no_op_when_already_latest() { let tmp = tempdir(); let yaml_path = tmp.path().join("project.yaml"); std::fs::write(&yaml_path, v2_body()).unwrap(); let outcome = ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap(); assert_eq!(outcome.migrated_from, None); // File unchanged. let on_disk = std::fs::read_to_string(&yaml_path).unwrap(); assert_eq!(on_disk, v2_body()); assert!(!tmp.path().join("project.yaml.v2.bak").exists()); } #[test] fn ensure_yaml_migrated_writes_upgraded_body_and_bak() { let tmp = tempdir(); let yaml_path = tmp.path().join("project.yaml"); std::fs::write(&yaml_path, v1_body()).unwrap(); let outcome = ensure_project_yaml_migrated(tmp.path(), ®istry_with_v1_to_v2()).unwrap(); assert_eq!(outcome.migrated_from, Some(1)); let on_disk = std::fs::read_to_string(&yaml_path).unwrap(); assert!(on_disk.contains("version: 2"), "got: {on_disk}"); let bak = tmp.path().join("project.yaml.v1.bak"); assert!(bak.exists()); assert!( std::fs::read_to_string(&bak) .unwrap() .contains("version: 1") ); } #[test] fn ensure_yaml_migrated_handles_missing_yaml() { let tmp = tempdir(); // No project.yaml exists. let outcome = ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap(); assert_eq!(outcome.migrated_from, None); assert!(outcome.body.is_empty()); } #[test] fn migrator_that_returns_internal_error_propagates() { let bad: MigrateFn = |_| Err(MigrateError::VersionParse("simulated".to_string())); let registry = MigratorRegistry { migrators: vec![bad], }; let tmp = tempdir(); let err = migrate_to_latest(&v1_body(), ®istry, tmp.path()).expect_err("must fail"); match err { MigrateError::StepFailed { from, to, .. } => { assert_eq!(from, 1); assert_eq!(to, 2); } other => panic!("expected StepFailed, got {other:?}"), } } }