9e2372b039
serde_yml (RUSTSEC-2025-0068) and its libyml backend (RUSTSEC-2025-0067) are archived, unsound, and unmaintained with no patched version. Swap to serde_norway, the maintained serde_yaml fork on unsafe-libyaml-norway — a drop-in for our from_str / to_string / Value usage across persistence, undo, and the catalog parser. Clears both advisories (cargo audit / osv-scanner / grype all clean; serde_yml + libyml gone from the tree). No behaviour change; full suite 2151/0/1.
460 lines
15 KiB
Rust
460 lines
15 KiB
Rust
//! 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<N>.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<String, MigrateError>;
|
|
|
|
/// 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<MigrateFn>,
|
|
}
|
|
|
|
impl MigratorRegistry {
|
|
/// Production-default registry: empty. As new versions
|
|
/// land, register the migrators here in source-version
|
|
/// order.
|
|
#[must_use]
|
|
pub const fn production() -> Self {
|
|
Self {
|
|
migrators: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// 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<Self>,
|
|
},
|
|
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<u32>,
|
|
}
|
|
|
|
/// 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_path>/project.yaml.v<N>.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<MigrationOutcome, MigrateError> {
|
|
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<MigrationOutcome, MigrateError> {
|
|
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<u32, MigrateError> {
|
|
#[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()
|
|
}
|
|
|
|
#[test]
|
|
fn production_registry_latest_version_is_1() {
|
|
let r = MigratorRegistry::production();
|
|
assert_eq!(r.latest_version(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn no_migration_runs_when_body_already_latest() {
|
|
let tmp = tempdir();
|
|
let outcome = migrate_to_latest(
|
|
&v1_body(),
|
|
&MigratorRegistry::production(),
|
|
tmp.path(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(outcome.body, v1_body());
|
|
assert_eq!(outcome.migrated_from, None);
|
|
// No .bak written when nothing migrated.
|
|
let bak = tmp.path().join("project.yaml.v1.bak");
|
|
assert!(!bak.exists(), "no .bak when no migration");
|
|
}
|
|
|
|
#[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: 1 }),
|
|
"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<String, MigrateError> {
|
|
// 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_on_v1_with_empty_registry() {
|
|
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(),
|
|
&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, v1_body());
|
|
assert!(!tmp.path().join("project.yaml.v1.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:?}"),
|
|
}
|
|
}
|
|
}
|