Files
rdbms-playground/src/persistence/migrations.rs
T
claude@clouddev1 9e2372b039 fix: migrate off unsound serde_yml to serde_norway
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.
2026-06-02 14:34:34 +00:00

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(&current_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(),
&registry_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(), &registry, 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(),
&registry_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(), &registry, 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:?}"),
}
}
}