use tracing::{info, warn};
pub mod builder;
pub mod context;
#[cfg(feature = "diagnostics")]
pub mod diagnostic;
pub mod engine;
pub mod error;
pub mod fields;
pub mod migrate_from;
pub mod node;
pub mod provided;
pub mod registry;
pub mod set;
pub mod step;
use crate::store::moved::Moved;
use crate::store::{StorageError, meta, one_line};
use amethystate_core::path::StorePath;
pub use context::MigrationContext;
pub use error::MigrationError;
pub use step::{RunStep, StepResult};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct SchemaDiff {
pub added: Vec<meta::StoredFieldEntry>,
pub removed: Vec<meta::StoredFieldEntry>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct NaggingRecord {
pub prefix: StorePath,
pub id: Option<String>,
pub diff: Option<SchemaDiff>,
pub moved: Vec<Moved>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct AppliedStep {
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub target_version: u32,
pub description: Option<String>,
pub applied_at: u64,
}
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct MigrationReport {
pub components: Vec<ComponentResult>,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct ComponentResult {
pub prefixes: Vec<StorePath>,
pub outcome: ComponentOutcome,
pub nagging: Vec<NaggingRecord>,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ComponentOutcome {
Committed {
steps: Vec<AppliedStep>,
},
Skipped(NotMigrated),
Failed {
error: error_stack::Report<StorageError>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NotMigrated {
UpToDate,
BookkeepingLost { taken_as: u32 },
}
impl NaggingRecord {
pub fn lineage(&self) -> crate::schema::Lineage {
crate::schema::Lineage {
prefix: self.prefix.clone(),
id: self.id.clone(),
}
}
}
impl MigrationReport {
pub fn has_failures(&self) -> bool {
self.components
.iter()
.any(|c| matches!(c.outcome, ComponentOutcome::Failed { .. }))
}
pub fn failures(&self) -> impl Iterator<Item = &error_stack::Report<StorageError>> {
self.components.iter().filter_map(|c| match &c.outcome {
ComponentOutcome::Failed { error } => Some(error),
_ => None,
})
}
pub fn has_drift(&self) -> bool {
self.components.iter().any(|c| !c.nagging.is_empty())
}
fn named(prefixes: &[StorePath]) -> String {
prefixes
.iter()
.map(StorePath::to_string)
.collect::<Vec<_>>()
.join(", ")
}
pub fn log_to_tracing(&self) {
self.nagging_to_tracing();
for comp in &self.components {
match &comp.outcome {
ComponentOutcome::Committed { steps } => {
for step in steps {
info!(
"✅ Applied: {} v{} ({})",
step.prefix,
step.target_version,
step.description.as_deref().unwrap_or("no description")
);
}
}
ComponentOutcome::Failed { error } => {
tracing::error!(
"❌ Component [{}] failed: {}",
Self::named(&comp.prefixes),
one_line(error)
);
tracing::error!(
" Transaction rolled back. Data for these prefixes remains unchanged."
);
}
ComponentOutcome::Skipped(NotMigrated::UpToDate) if comp.nagging.is_empty() => {
tracing::debug!(
"⏩ Component [{}] is up to date",
Self::named(&comp.prefixes)
);
}
ComponentOutcome::Skipped(NotMigrated::UpToDate) => {
tracing::debug!(
"⏩ Component [{}] is at the version this build declares, and its shape \
is not: see the drift above",
Self::named(&comp.prefixes)
);
}
ComponentOutcome::Skipped(NotMigrated::BookkeepingLost { taken_as }) => {
warn!(
"⚠️ Component [{}] held keys and nothing recorded what version they were \
at, so they are taken to stand at v{taken_as} - what this build declares \
- and no step ran over them. The bookkeeping that would have said which \
steps had already run is gone, and running them again is how one rename \
becomes two. Check the data if this store was written by an older build",
Self::named(&comp.prefixes)
);
}
}
}
}
#[cfg(any(not(feature = "diagnostics"), test))]
fn drift_lines(&self) -> Vec<String> {
use crate::store::moved::{Moved, Verdict};
let mut lines = Vec::new();
for nag in self.components.iter().flat_map(|comp| comp.nagging.iter()) {
lines.push(format!(
"⚠️ Schema drift detected in prefix '{}'",
nag.prefix
));
let mut moved: Vec<&Moved> = nag.moved.iter().collect();
moved.sort_by_key(|one| match one.verdict() {
Verdict::Breaks => 0,
Verdict::LookAtTheGround => 1,
Verdict::Harmless => 2,
});
for one in moved {
let said = match one.verdict() {
Verdict::Breaks => "breaks",
Verdict::LookAtTheGround => "look",
Verdict::Harmless => "harmless",
};
let whole = Moved {
at: nag.prefix.join(&one.at),
what: one.what.clone(),
};
lines.push(format!(" {said}: {whole}"));
}
lines.push(
" Suggestion: raise the struct's version and write a step for it, or declare \
the released places again."
.to_string(),
);
}
lines
}
#[cfg(not(feature = "diagnostics"))]
fn nagging_to_tracing(&self) {
for line in self.drift_lines() {
warn!("{line}");
}
}
#[cfg(feature = "diagnostics")]
fn nagging_to_tracing(&self) {
for one in self.drift() {
warn!("{}", diagnostic::rendered(&one));
}
}
}
pub(crate) trait Migration: Send + Sync {
fn target_version(&self) -> u32;
fn description(&self) -> Option<&str> {
None
}
fn run(&self, ctx: &mut MigrationContext) -> StepResult<()>;
}
pub struct MigrationPlan {
pub(crate) steps: Vec<Box<dyn Migration>>,
}
impl MigrationPlan {
pub fn new() -> Self {
Self { steps: Vec::new() }
}
pub fn step<F>(mut self, version: u32, description: &str, f: F) -> Self
where
F: Fn(&mut MigrationContext) -> StepResult<()> + Send + Sync + 'static,
{
struct ClosureMigration<F> {
v: u32,
d: String,
f: F,
}
impl<F> Migration for ClosureMigration<F>
where
F: Fn(&mut MigrationContext) -> StepResult<()> + Send + Sync + 'static,
{
fn target_version(&self) -> u32 {
self.v
}
fn description(&self) -> Option<&str> {
Some(&self.d)
}
fn run(&self, ctx: &mut MigrationContext) -> StepResult<()> {
(self.f)(ctx)
}
}
self.steps.push(Box::new(ClosureMigration {
v: version,
d: description.to_string(),
f,
}));
self.steps.sort_by_key(|s| s.target_version());
self
}
}
impl Default for MigrationPlan {
fn default() -> Self {
Self::new()
}
}