use std::sync::Arc;
use sqlx::PgPool;
use uuid::Uuid;
use crate::migration::apply::{ApplyError, ApplyFuture, ArtifactApplyResult, MigrationAuditSink};
use crate::runtime::canonical_store::postgres::PostgresCanonicalStore;
use crate::runtime::canonical_store::system_store::{
MigrationAuditStore, MigrationOpInsert, MigrationRunInsert, MigrationRunState, OpLedgerStatus,
};
use crate::runtime::system::SystemCatalogConfig;
pub struct PostgresMigrationAuditSink {
store: Arc<dyn MigrationAuditStore>,
}
impl PostgresMigrationAuditSink {
pub fn new(pool: PgPool, config: &SystemCatalogConfig) -> Self {
let store = PostgresCanonicalStore::new(pool, "primary", "").with_migration_relations(
config.migration_runs_relation(),
config.migration_op_ledger_relation(),
);
Self {
store: Arc::new(store),
}
}
pub fn with_store(store: Arc<dyn MigrationAuditStore>) -> Self {
Self { store }
}
}
impl MigrationAuditSink for PostgresMigrationAuditSink {
fn start_run<'a>(
&'a self,
catalog_version: &'a str,
operations_hash: &'a str,
) -> ApplyFuture<'a, String> {
let store = Arc::clone(&self.store);
Box::pin(async move {
let insert = MigrationRunInsert {
project_id: String::new(),
catalog_version: catalog_version.to_string(),
operations_hash: operations_hash.to_string(),
approval_token: String::new(),
state: MigrationRunState::Applying,
};
let run_id: Uuid = store
.start_migration_run(&insert)
.await
.map_err(|err| ApplyError::Io(err.to_string()))?;
Ok(run_id.to_string())
})
}
fn record_op<'a>(
&'a self,
run_id: &'a str,
index: usize,
result: &'a ArtifactApplyResult,
) -> ApplyFuture<'a, ()> {
let store = Arc::clone(&self.store);
Box::pin(async move {
let run_uuid: Uuid = run_id
.parse()
.map_err(|err| ApplyError::Io(format!("invalid run_id: {err}")))?;
let status = if result.error.is_some() {
OpLedgerStatus::Failed
} else if result.skipped {
OpLedgerStatus::Skipped
} else {
OpLedgerStatus::Applied
};
let index_i32 = i32::try_from(index).unwrap_or(i32::MAX);
let insert = MigrationOpInsert {
run_id: run_uuid,
operation_index: index_i32,
backend: result.backend.clone(),
resource_uri: format!("udb://{}/{}", result.backend, result.rel_path),
operation_kind: "apply".to_string(),
status,
rollback_json: serde_json::Value::Object(Default::default()),
error: result.error.clone().unwrap_or_default(),
};
store
.record_migration_op(&insert)
.await
.map_err(|err| ApplyError::Io(err.to_string()))?;
Ok(())
})
}
fn finish_run<'a>(
&'a self,
run_id: &'a str,
state: &'a str,
error: &'a str,
) -> ApplyFuture<'a, ()> {
let store = Arc::clone(&self.store);
Box::pin(async move {
let run_uuid: Uuid = run_id
.parse()
.map_err(|err| ApplyError::Io(format!("invalid run_id: {err}")))?;
let new_state = MigrationRunState::parse(state).ok_or_else(|| {
ApplyError::Io(format!(
"unknown migration run state token '{state}' (must be one of {:?})",
MigrationRunState::all()
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
))
})?;
store
.finish_migration_run(run_uuid, new_state, error)
.await
.map_err(|err| ApplyError::Io(err.to_string()))?;
Ok(())
})
}
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use super::*;
use crate::migration::apply::ArtifactApplyResult;
use crate::runtime::canonical_store::sqlite::SqliteCanonicalStore;
use sqlx::sqlite::SqlitePoolOptions;
async fn fresh_sink() -> PostgresMigrationAuditSink {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
let store = Arc::new(SqliteCanonicalStore::new(pool, "test", "udb_outbox_events"));
MigrationAuditStore::ensure_migration_audit_tables(store.as_ref())
.await
.expect("DDL");
PostgresMigrationAuditSink::with_store(store)
}
#[tokio::test]
async fn start_run_returns_uuid_string_and_lands_in_applying() {
let sink = fresh_sink().await;
let run_id = sink.start_run("v1", "sha256:abc").await.expect("start_run");
let parsed: Uuid = run_id.parse().expect("uuid");
sink.finish_run(&run_id, "COMPLETED", "")
.await
.expect("finish_run accepts the run we just started");
let _ = parsed;
}
#[tokio::test]
async fn record_op_maps_result_to_status_correctly() {
let sink = fresh_sink().await;
let run_id = sink.start_run("v1", "h").await.unwrap();
let ok = ArtifactApplyResult {
backend: "postgres".to_string(),
rel_path: "001.sql".to_string(),
applied: true,
error: None,
skipped: false,
};
sink.record_op(&run_id, 0, &ok)
.await
.expect("record APPLIED");
let skipped = ArtifactApplyResult {
backend: "postgres".to_string(),
rel_path: "002.sql".to_string(),
applied: true,
error: None,
skipped: true,
};
sink.record_op(&run_id, 1, &skipped)
.await
.expect("record SKIPPED");
let failed = ArtifactApplyResult {
backend: "postgres".to_string(),
rel_path: "003.sql".to_string(),
applied: true,
error: Some("syntax error".to_string()),
skipped: false,
};
sink.record_op(&run_id, 2, &failed)
.await
.expect("record FAILED");
sink.finish_run(&run_id, "ERROR", "one or more artifacts failed")
.await
.expect("finish_run ERROR");
}
#[tokio::test]
async fn finish_run_rejects_unknown_state_token() {
let sink = fresh_sink().await;
let run_id = sink.start_run("v1", "h").await.unwrap();
let err = sink
.finish_run(&run_id, "COMPLETE", "")
.await
.expect_err("must reject");
let msg = format!("{err:?}");
assert!(msg.contains("unknown migration run state"));
}
#[tokio::test]
async fn record_op_rejects_malformed_run_id() {
let sink = fresh_sink().await;
let result = ArtifactApplyResult {
backend: "postgres".to_string(),
rel_path: "x.sql".to_string(),
applied: true,
error: None,
skipped: false,
};
let err = sink
.record_op("not-a-uuid", 0, &result)
.await
.expect_err("must reject");
let msg = format!("{err:?}");
assert!(msg.contains("invalid run_id"));
}
}