use std::fmt;
use std::future::Future;
use std::pin::Pin;
use crate::generation::GeneratedArtifact;
#[derive(Debug)]
pub enum ApplyError {
Io(String),
BackendRejected { backend: String, message: String },
AlreadyApplied,
Unreachable(String),
}
impl fmt::Display for ApplyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ApplyError::Io(msg) => write!(f, "I/O error: {msg}"),
ApplyError::BackendRejected { backend, message } => {
write!(f, "{backend} rejected artifact: {message}")
}
ApplyError::AlreadyApplied => write!(f, "artifact already applied"),
ApplyError::Unreachable(addr) => write!(f, "backend unreachable: {addr}"),
}
}
}
impl std::error::Error for ApplyError {}
pub type ApplyFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApplyError>> + Send + 'a>>;
pub trait ApplyTarget: Send + Sync {
fn backend_name(&self) -> &'static str;
fn apply_artifact<'a>(&'a self, artifact: &'a GeneratedArtifact) -> ApplyFuture<'a, ()>;
fn verify_applied<'a>(&'a self, artifact: &'a GeneratedArtifact) -> ApplyFuture<'a, bool>;
}
#[cfg(test)]
pub struct LoggingApplyTarget {
name: &'static str,
}
#[cfg(test)]
impl LoggingApplyTarget {
pub fn new(name: &'static str) -> Self {
Self { name }
}
}
#[cfg(test)]
impl ApplyTarget for LoggingApplyTarget {
fn backend_name(&self) -> &'static str {
self.name
}
fn apply_artifact<'a>(&'a self, artifact: &'a GeneratedArtifact) -> ApplyFuture<'a, ()> {
Box::pin(async move {
tracing::info!(
backend = self.name,
rel_path = %artifact.rel_path,
kind = %artifact.kind,
"UDB apply (logging stub): would apply artifact"
);
Ok(())
})
}
fn verify_applied<'a>(&'a self, _artifact: &'a GeneratedArtifact) -> ApplyFuture<'a, bool> {
Box::pin(async { Ok(false) })
}
}
#[derive(Debug, Clone)]
pub struct ArtifactApplyResult {
pub rel_path: String,
pub backend: String,
pub applied: bool,
pub skipped: bool,
pub error: Option<String>,
}
pub async fn apply_artifacts(
target: &dyn ApplyTarget,
artifacts: &[GeneratedArtifact],
) -> Vec<ArtifactApplyResult> {
let mut results = Vec::with_capacity(artifacts.len());
for artifact in artifacts {
let already_applied = match target.verify_applied(artifact).await {
Ok(applied) => applied,
Err(err) => {
results.push(ArtifactApplyResult {
rel_path: artifact.rel_path.clone(),
backend: target.backend_name().to_string(),
applied: false,
skipped: false,
error: Some(format!(
"verify_applied failed (not applied to avoid duplicate): {err}"
)),
});
continue;
}
};
if already_applied {
results.push(ArtifactApplyResult {
rel_path: artifact.rel_path.clone(),
backend: target.backend_name().to_string(),
applied: false,
skipped: true,
error: None,
});
continue;
}
match target.apply_artifact(artifact).await {
Ok(()) => results.push(ArtifactApplyResult {
rel_path: artifact.rel_path.clone(),
backend: target.backend_name().to_string(),
applied: true,
skipped: false,
error: None,
}),
Err(err) => results.push(ArtifactApplyResult {
rel_path: artifact.rel_path.clone(),
backend: target.backend_name().to_string(),
applied: false,
skipped: false,
error: Some(err.to_string()),
}),
}
}
results
}
pub trait MigrationAuditSink: Send + Sync {
fn start_run<'a>(
&'a self,
catalog_version: &'a str,
operations_hash: &'a str,
) -> ApplyFuture<'a, String>;
fn record_op<'a>(
&'a self,
run_id: &'a str,
index: usize,
result: &'a ArtifactApplyResult,
) -> ApplyFuture<'a, ()>;
fn finish_run<'a>(
&'a self,
run_id: &'a str,
state: &'a str,
error: &'a str,
) -> ApplyFuture<'a, ()>;
}
pub struct NoopMigrationAuditSink;
impl MigrationAuditSink for NoopMigrationAuditSink {
fn start_run<'a>(
&'a self,
_catalog_version: &'a str,
_operations_hash: &'a str,
) -> ApplyFuture<'a, String> {
Box::pin(async { Ok("00000000-0000-0000-0000-000000000000".to_string()) })
}
fn record_op<'a>(
&'a self,
_run_id: &'a str,
_index: usize,
_result: &'a ArtifactApplyResult,
) -> ApplyFuture<'a, ()> {
Box::pin(async { Ok(()) })
}
fn finish_run<'a>(
&'a self,
_run_id: &'a str,
_state: &'a str,
_error: &'a str,
) -> ApplyFuture<'a, ()> {
Box::pin(async { Ok(()) })
}
}
pub async fn apply_artifacts_audited(
target: &dyn ApplyTarget,
artifacts: &[GeneratedArtifact],
sink: &dyn MigrationAuditSink,
catalog_version: &str,
operations_hash: &str,
) -> Vec<ArtifactApplyResult> {
let run_id = match sink.start_run(catalog_version, operations_hash).await {
Ok(id) => id,
Err(err) => {
tracing::warn!(error = %err, "audit: failed to start migration run — continuing without audit");
"00000000-0000-0000-0000-000000000000".to_string()
}
};
let results = apply_artifacts(target, artifacts).await;
let has_error = results.iter().any(|r| r.error.is_some());
for (idx, result) in results.iter().enumerate() {
if let Err(err) = sink.record_op(&run_id, idx, result).await {
tracing::warn!(error = %err, run_id = %run_id, idx = idx, "audit: failed to record op ledger entry");
}
}
let (state, error_msg) = if has_error {
let errors: Vec<_> = results.iter().filter_map(|r| r.error.as_deref()).collect();
("ERROR", errors.join("; "))
} else {
("COMPLETED", String::new())
};
if let Err(err) = sink.finish_run(&run_id, state, &error_msg).await {
tracing::warn!(error = %err, run_id = %run_id, "audit: failed to finish migration run");
}
results
}
pub async fn apply_artifacts_phased(
run_id: &str,
target: &dyn ApplyTarget,
artifacts: &[GeneratedArtifact],
sink: &dyn MigrationAuditSink,
ledger: &dyn crate::migration::phase_runner::PhaseLedger,
catalog_version: &str,
operations_hash: &str,
) -> Result<
(
crate::migration::phase_runner::RunnerOutcome,
Vec<ArtifactApplyResult>,
),
String,
> {
use std::sync::Mutex;
use crate::migration::diff_backends::MigrationPhase;
use crate::migration::phase_runner::{MigrationPhaseHook, run_to_completion};
struct ApplyHook<'a> {
target: &'a dyn ApplyTarget,
artifacts: &'a [GeneratedArtifact],
sink: &'a dyn MigrationAuditSink,
catalog_version: &'a str,
operations_hash: &'a str,
results: Mutex<Vec<ArtifactApplyResult>>,
}
#[async_trait::async_trait]
impl MigrationPhaseHook for ApplyHook<'_> {
async fn run(&self, phase: MigrationPhase) -> Result<(), String> {
match phase {
MigrationPhase::Prepare => {
for art in self.artifacts {
if let Err(err) = self.target.verify_applied(art).await {
return Err(format!(
"prepare: verify_applied('{}') failed: {err}",
art.rel_path
));
}
}
Ok(())
}
MigrationPhase::Backfill => {
let results = apply_artifacts_audited(
self.target,
self.artifacts,
self.sink,
self.catalog_version,
self.operations_hash,
)
.await;
let errs: Vec<String> =
results.iter().filter_map(|r| r.error.clone()).collect();
*self.results.lock().expect("ApplyHook.results poisoned") = results;
if errs.is_empty() {
Ok(())
} else {
Err(format!("backfill errors: {}", errs.join("; ")))
}
}
MigrationPhase::Validate => {
for art in self.artifacts {
match self.target.verify_applied(art).await {
Ok(true) => {}
Ok(false) => {
return Err(format!(
"validate: artifact '{}' did not verify after apply",
art.rel_path
));
}
Err(err) => {
return Err(format!(
"validate: verify_applied('{}') failed: {err}",
art.rel_path
));
}
}
}
Ok(())
}
MigrationPhase::Switch | MigrationPhase::Cleanup => Ok(()),
}
}
}
let hook = ApplyHook {
target,
artifacts,
sink,
catalog_version,
operations_hash,
results: Mutex::new(Vec::new()),
};
let outcome = run_to_completion(run_id, ledger, &hook).await?;
let results = hook
.results
.into_inner()
.expect("ApplyHook.results poisoned");
Ok((outcome, results))
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_artifact(rel_path: &str) -> GeneratedArtifact {
GeneratedArtifact {
rel_path: rel_path.to_string(),
kind: "bootstrap_test".to_string(),
schema: "test".to_string(),
table: "dummy".to_string(),
content: "-- dummy\n".to_string(),
}
}
#[tokio::test]
async fn logging_target_apply_always_ok() {
let target = LoggingApplyTarget::new("test");
let artifact = dummy_artifact("test/001_dummy.sql");
assert!(target.apply_artifact(&artifact).await.is_ok());
}
#[tokio::test]
async fn logging_target_verify_always_false() {
let target = LoggingApplyTarget::new("test");
let artifact = dummy_artifact("test/001_dummy.sql");
assert!(!target.verify_applied(&artifact).await.unwrap());
}
#[tokio::test]
async fn apply_artifacts_collects_results() {
let target = LoggingApplyTarget::new("test");
let artifacts = vec![
dummy_artifact("postgres/001_users.sql"),
dummy_artifact("postgres/002_docs.sql"),
];
let results = apply_artifacts(&target, &artifacts).await;
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.applied && r.error.is_none()));
}
#[tokio::test]
async fn noop_audit_sink_start_run_returns_zero_uuid() {
let sink = NoopMigrationAuditSink;
let run_id = sink.start_run("v1.0", "hash123").await.unwrap();
assert_eq!(run_id, "00000000-0000-0000-0000-000000000000");
}
#[tokio::test]
async fn noop_audit_sink_record_op_is_ok() {
let sink = NoopMigrationAuditSink;
let result = ArtifactApplyResult {
rel_path: "postgres/001.sql".to_string(),
backend: "postgres".to_string(),
applied: true,
skipped: false,
error: None,
};
assert!(sink.record_op("run-id", 0, &result).await.is_ok());
}
#[tokio::test]
async fn c_apply_artifacts_phased_records_all_five_phases() {
use std::collections::HashSet;
use std::sync::Mutex;
use crate::migration::phase_runner::{
MemoryPhaseLedger, PhaseLedger, PhaseStatus, RunnerOutcome,
};
struct TrackingTarget {
applied: Mutex<HashSet<String>>,
}
impl ApplyTarget for TrackingTarget {
fn backend_name(&self) -> &'static str {
"tracking"
}
fn apply_artifact<'a>(
&'a self,
artifact: &'a GeneratedArtifact,
) -> ApplyFuture<'a, ()> {
Box::pin(async move {
self.applied
.lock()
.unwrap()
.insert(artifact.rel_path.clone());
Ok(())
})
}
fn verify_applied<'a>(
&'a self,
artifact: &'a GeneratedArtifact,
) -> ApplyFuture<'a, bool> {
Box::pin(
async move { Ok(self.applied.lock().unwrap().contains(&artifact.rel_path)) },
)
}
}
let target = TrackingTarget {
applied: Mutex::new(HashSet::new()),
};
let sink = NoopMigrationAuditSink;
let ledger = MemoryPhaseLedger::default();
let artifacts = vec![
dummy_artifact("postgres/001_users.sql"),
dummy_artifact("postgres/002_docs.sql"),
];
let (outcome, results) = apply_artifacts_phased(
"run-c-1", &target, &artifacts, &sink, &ledger, "v1.0", "hash-c-1",
)
.await
.expect("phased apply failed");
assert!(matches!(outcome, RunnerOutcome::Completed { .. }));
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.applied && r.error.is_none()));
let recs = ledger.load("run-c-1").await.unwrap();
assert_eq!(recs.len(), 5, "expected 5 phase rows, got {recs:?}");
for r in &recs {
assert_eq!(
r.status,
PhaseStatus::Completed,
"phase {} not completed: {r:?}",
r.phase.as_str()
);
}
}
#[tokio::test]
async fn c_phased_apply_pauses_when_validate_fails() {
use crate::migration::diff_backends::MigrationPhase;
use crate::migration::phase_runner::{MemoryPhaseLedger, RunnerOutcome};
let target = LoggingApplyTarget::new("never_verifies");
let sink = NoopMigrationAuditSink;
let ledger = MemoryPhaseLedger::default();
let artifacts = vec![dummy_artifact("test/001.sql")];
let (outcome, _) = apply_artifacts_phased(
"run-c-2", &target, &artifacts, &sink, &ledger, "v1.0", "hash-c-2",
)
.await
.expect("runner errored");
match outcome {
RunnerOutcome::Paused { phase, .. } => {
assert_eq!(phase, MigrationPhase::Validate);
}
other => panic!("expected Paused at Validate, got {other:?}"),
}
}
#[tokio::test]
async fn c_phased_apply_all_already_applied_completes_as_skipped() {
use crate::migration::phase_runner::{MemoryPhaseLedger, RunnerOutcome};
struct AlreadyAppliedTarget;
impl ApplyTarget for AlreadyAppliedTarget {
fn backend_name(&self) -> &'static str {
"already_applied"
}
fn apply_artifact<'a>(
&'a self,
_artifact: &'a GeneratedArtifact,
) -> ApplyFuture<'a, ()> {
Box::pin(async { Ok(()) })
}
fn verify_applied<'a>(
&'a self,
_artifact: &'a GeneratedArtifact,
) -> ApplyFuture<'a, bool> {
Box::pin(async { Ok(true) })
}
}
let target = AlreadyAppliedTarget;
let sink = NoopMigrationAuditSink;
let ledger = MemoryPhaseLedger::default();
let artifacts = vec![dummy_artifact("test/already.sql")];
let (outcome, results) = apply_artifacts_phased(
"run-c-already",
&target,
&artifacts,
&sink,
&ledger,
"v1.0",
"hash-already",
)
.await
.expect("phased apply failed");
assert!(matches!(outcome, RunnerOutcome::Completed { .. }));
assert_eq!(results.len(), 1);
assert!(results[0].skipped);
assert!(!results[0].applied);
assert!(results[0].error.is_none());
}
#[tokio::test]
async fn apply_artifacts_audited_completes_without_error() {
let target = LoggingApplyTarget::new("test");
let sink = NoopMigrationAuditSink;
let artifacts = vec![
dummy_artifact("postgres/001_users.sql"),
dummy_artifact("postgres/002_docs.sql"),
];
let results = apply_artifacts_audited(&target, &artifacts, &sink, "v1.0", "hash123").await;
assert_eq!(results.len(), 2);
assert!(results.iter().all(|r| r.applied && r.error.is_none()));
}
}