The blob-removal commit (6b4c4dc) failed CI's fmt gate — long assert! lines
and a doc comment that stock rustfmt wraps. No behaviour change; just
`cargo fmt`. Verified via `nix develop -c cargo fmt --check`.
541 lines
19 KiB
Rust
541 lines
19 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>;
|
|
|
|
/// 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<String, MigrateError> {
|
|
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<MigrateFn>,
|
|
}
|
|
|
|
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<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>,
|
|
}
|
|
|
|
/// 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_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()
|
|
}
|
|
|
|
/// 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<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_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:?}"),
|
|
}
|
|
}
|
|
}
|