use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ProjectionTaskStatus {
Pending,
InProgress,
Completed,
Failed,
DeadLetter,
}
impl ProjectionTaskStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "PENDING",
Self::InProgress => "IN_PROGRESS",
Self::Completed => "COMPLETED",
Self::Failed => "FAILED",
Self::DeadLetter => "DEAD_LETTER",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"PENDING" => Some(Self::Pending),
"IN_PROGRESS" => Some(Self::InProgress),
"COMPLETED" => Some(Self::Completed),
"FAILED" => Some(Self::Failed),
"DEAD_LETTER" => Some(Self::DeadLetter),
_ => None,
}
}
pub fn is_claimable(self) -> bool {
matches!(self, Self::Pending | Self::Failed)
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::DeadLetter)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectionOperation {
Upsert,
Delete,
}
impl ProjectionOperation {
pub fn as_str(self) -> &'static str {
match self {
Self::Upsert => "upsert",
Self::Delete => "delete",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"upsert" => Some(Self::Upsert),
"delete" => Some(Self::Delete),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectionTaskInsert {
pub idempotency_key: String,
pub project_id: String,
pub manifest_checksum: String,
pub message_type: String,
pub source_schema: String,
pub source_table: String,
pub source_row_key: serde_json::Value,
pub operation: ProjectionOperation,
pub target_backend: String,
pub target_instance: String,
pub projection_kind: String,
pub resource_name: String,
pub target_options: serde_json::Value,
pub source_payload: serde_json::Value,
pub source_checksum: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectionTaskRow {
pub task_id: Uuid,
pub idempotency_key: String,
pub project_id: String,
pub target_backend: String,
pub target_instance: String,
pub projection_kind: String,
pub resource_name: String,
pub operation: ProjectionOperation,
pub source_row_key: serde_json::Value,
pub target_options: serde_json::Value,
pub source_payload: serde_json::Value,
pub source_checksum: String,
pub status: ProjectionTaskStatus,
pub retry_count: i32,
pub last_error: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub next_retry_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectionClaimFilter {
pub batch_size: i64,
pub max_retries: i32,
pub target_backend: Option<String>,
pub target_instance: Option<String>,
pub project_id: Option<String>,
}
impl Default for ProjectionClaimFilter {
fn default() -> Self {
Self {
batch_size: 50,
max_retries: 5,
target_backend: None,
target_instance: None,
project_id: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectionTaskSummary {
pub pending: i64,
pub in_progress: i64,
pub completed: i64,
pub failed: i64,
pub dead_letter: i64,
}
impl ProjectionTaskSummary {
pub fn total(&self) -> i64 {
self.pending + self.in_progress + self.completed + self.failed + self.dead_letter
}
pub fn claimable(&self) -> i64 {
self.pending + self.failed
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PendingTaskMetric {
pub project_id: String,
pub target_backend: String,
pub target_instance: String,
pub projection_kind: String,
pub pending: i64,
pub oldest_age_seconds: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeadLetterGroup {
pub source_table: String,
pub target_backend: String,
pub target_instance: String,
pub dead_count: i64,
}
#[derive(Debug)]
pub enum SystemStoreError {
Io {
backend: &'static str,
source: String,
},
Query {
backend: &'static str,
sql: String,
source: String,
},
UnsupportedVersion {
backend: &'static str,
required: &'static str,
detail: String,
},
SchemaMismatch {
backend: &'static str,
detail: String,
},
InvalidInput(String),
}
impl std::fmt::Display for SystemStoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { backend, source } => {
write!(f, "{backend} system store I/O failure: {source}")
}
Self::Query {
backend,
sql,
source,
} => {
let preview: String = sql.chars().take(80).collect();
write!(
f,
"{backend} system store query failed: {source} (sql preview: '{preview}…')"
)
}
Self::UnsupportedVersion {
backend,
required,
detail,
} => write!(f, "{backend} system store requires {required}: {detail}"),
Self::SchemaMismatch { backend, detail } => {
write!(f, "{backend} system store schema mismatch: {detail}")
}
Self::InvalidInput(msg) => write!(f, "system store invalid input: {msg}"),
}
}
}
impl std::error::Error for SystemStoreError {}
impl SystemStoreError {
pub fn io(backend: &'static str, err: impl std::fmt::Display) -> Self {
Self::Io {
backend,
source: err.to_string(),
}
}
pub fn query(
backend: &'static str,
sql: impl Into<String>,
err: impl std::fmt::Display,
) -> Self {
Self::Query {
backend,
sql: sql.into(),
source: err.to_string(),
}
}
}
pub type SystemStoreResult<T> = Result<T, SystemStoreError>;
#[async_trait]
pub trait ProjectionTaskStore: Send + Sync {
fn backend_label(&self) -> &'static str;
async fn ensure_projection_tables(&self) -> SystemStoreResult<()>;
async fn enqueue_projection_task(&self, task: &ProjectionTaskInsert)
-> SystemStoreResult<Uuid>;
async fn claim_projection_tasks(
&self,
filter: &ProjectionClaimFilter,
) -> SystemStoreResult<Vec<ProjectionTaskRow>>;
async fn mark_projection_task_completed(&self, task_id: Uuid) -> SystemStoreResult<()>;
async fn mark_projection_task_failed(
&self,
task_id: Uuid,
new_retry_count: i32,
new_status: ProjectionTaskStatus,
error: &str,
) -> SystemStoreResult<()>;
async fn requeue_dead_letter_tasks(
&self,
target_backend: Option<&str>,
) -> SystemStoreResult<i64>;
async fn reset_stale_in_progress_tasks(&self, stale_after: Duration) -> SystemStoreResult<i64>;
async fn projection_task_summary(&self) -> SystemStoreResult<ProjectionTaskSummary>;
async fn pending_task_metrics(&self, limit: i64) -> SystemStoreResult<Vec<PendingTaskMetric>>;
async fn dead_letter_groups(&self, limit: i64) -> SystemStoreResult<Vec<DeadLetterGroup>>;
async fn requeue_dead_letter_by_source(
&self,
source_table: &str,
target_backend: &str,
target_instance: &str,
) -> SystemStoreResult<i64>;
async fn pending_projection_task_count(
&self,
idempotency_keys: &[String],
) -> SystemStoreResult<i64>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SagaStatus {
Indeterminate,
InProgress,
Pending,
Committed,
Compensated,
Failed,
InDoubt,
FailedCompensation,
ManualReview,
}
impl SagaStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Indeterminate => "indeterminate",
Self::InProgress => "in_progress",
Self::Pending => "pending",
Self::Committed => "committed",
Self::Compensated => "compensated",
Self::Failed => "failed",
Self::InDoubt => "in_doubt",
Self::FailedCompensation => "failed_compensation",
Self::ManualReview => "manual_review",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"indeterminate" => Some(Self::Indeterminate),
"in_progress" => Some(Self::InProgress),
"pending" => Some(Self::Pending),
"committed" => Some(Self::Committed),
"compensated" => Some(Self::Compensated),
"failed" => Some(Self::Failed),
"in_doubt" => Some(Self::InDoubt),
"failed_compensation" => Some(Self::FailedCompensation),
"manual_review" => Some(Self::ManualReview),
_ => None,
}
}
pub fn all() -> &'static [SagaStatus] {
&[
Self::Indeterminate,
Self::InProgress,
Self::Pending,
Self::Committed,
Self::Compensated,
Self::Failed,
Self::InDoubt,
Self::FailedCompensation,
Self::ManualReview,
]
}
pub fn is_recoverable(self) -> bool {
matches!(self, Self::Indeterminate | Self::InProgress | Self::InDoubt)
}
pub fn is_terminal(self) -> bool {
matches!(
self,
Self::Committed | Self::Compensated | Self::ManualReview
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompensationStatus {
None,
Completed,
ManualReview,
RetryRequested,
}
impl CompensationStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Completed => "completed",
Self::ManualReview => "manual_review",
Self::RetryRequested => "retry_requested",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"none" => Some(Self::None),
"completed" => Some(Self::Completed),
"manual_review" => Some(Self::ManualReview),
"retry_requested" => Some(Self::RetryRequested),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaInsert {
pub tx_id: String,
pub tenant_id: String,
pub correlation_id: String,
pub backend_instance: String,
pub operation: String,
pub status: SagaStatus,
pub steps: serde_json::Value,
pub compensations: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaRow {
pub saga_id: Uuid,
pub tx_id: String,
pub tenant_id: String,
pub correlation_id: String,
pub status: SagaStatus,
pub backend_instance: String,
pub operation: String,
pub current_step: i32,
pub retry_count: i32,
pub recovery_attempts: i32,
pub compensation_status: CompensationStatus,
pub steps: serde_json::Value,
pub compensations: serde_json::Value,
pub last_error: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SagaListFilter {
pub tenant_id: Option<String>,
pub status: Option<SagaStatus>,
pub tx_id: Option<String>,
pub correlation_id: Option<String>,
pub limit: i64,
pub offset: i64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SagaSummary {
pub indeterminate: i64,
pub in_progress: i64,
pub pending: i64,
pub committed: i64,
pub compensated: i64,
pub failed: i64,
pub in_doubt: i64,
pub failed_compensation: i64,
pub manual_review: i64,
}
impl SagaSummary {
pub fn total(&self) -> i64 {
self.indeterminate
+ self.in_progress
+ self.pending
+ self.committed
+ self.compensated
+ self.failed
+ self.in_doubt
+ self.failed_compensation
+ self.manual_review
}
pub fn recoverable(&self) -> i64 {
self.indeterminate + self.in_progress + self.in_doubt + self.failed_compensation
}
}
#[async_trait]
pub trait SagaStore: Send + Sync {
fn backend_label(&self) -> &'static str;
async fn ensure_saga_tables(&self) -> SystemStoreResult<()>;
async fn record_saga(&self, saga: &SagaInsert) -> SystemStoreResult<Uuid>;
async fn get_saga(&self, saga_id: Uuid) -> SystemStoreResult<Option<SagaRow>>;
async fn list_sagas(&self, filter: &SagaListFilter) -> SystemStoreResult<Vec<SagaRow>>;
async fn update_saga_status(
&self,
saga_id: Uuid,
status: SagaStatus,
compensation_status: CompensationStatus,
) -> SystemStoreResult<()>;
async fn update_saga_statuses_batch(
&self,
saga_ids: &[Uuid],
status: SagaStatus,
compensation_status: CompensationStatus,
) -> SystemStoreResult<()> {
for &saga_id in saga_ids {
self.update_saga_status(saga_id, status, compensation_status)
.await?;
}
Ok(())
}
async fn mark_saga_manual_review(&self, saga_id: Uuid) -> SystemStoreResult<()>;
async fn request_saga_recompensation(&self, saga_id: Uuid) -> SystemStoreResult<()>;
async fn increment_recovery_attempts(
&self,
saga_id: Uuid,
error: &str,
) -> SystemStoreResult<i64>;
async fn claim_recoverable_sagas(
&self,
stale_after: std::time::Duration,
limit: i64,
) -> SystemStoreResult<Vec<SagaRow>>;
async fn mark_stale_in_progress_indeterminate(
&self,
stale_after: std::time::Duration,
) -> SystemStoreResult<i64>;
async fn saga_summary(&self) -> SystemStoreResult<SagaSummary>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminAuditInsert {
pub actor: String,
pub operation: String,
pub target: String,
pub request_json: serde_json::Value,
pub result: String,
pub tenant_id: String,
pub project_id: String,
pub correlation_id: String,
pub signer_key_id: String,
pub external_anchor: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminAuditRow {
pub audit_id: Uuid,
pub actor: String,
pub operation: String,
pub target: String,
pub request_json: serde_json::Value,
pub result: String,
pub tenant_id: String,
pub project_id: String,
pub correlation_id: String,
pub previous_hash: String,
pub current_hash: String,
pub signer_key_id: String,
pub external_anchor: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AdminAuditListFilter {
pub operation: Option<String>,
pub actor: Option<String>,
pub tenant_id: Option<String>,
pub project_id: Option<String>,
pub limit: i64,
pub offset: i64,
pub redact_request_json: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "outcome")]
pub enum AdminAuditChainReport {
Passed {
checked_count: i64,
last_hash: String,
},
Failed {
checked_count: i64,
first_broken_audit_id: Uuid,
reason: AdminAuditBreakReason,
expected_previous_hash: String,
actual_previous_hash: String,
expected_current_hash: String,
actual_current_hash: String,
},
}
impl AdminAuditChainReport {
pub fn is_passed(&self) -> bool {
matches!(self, Self::Passed { .. })
}
pub fn checked_count(&self) -> i64 {
match self {
Self::Passed { checked_count, .. } => *checked_count,
Self::Failed { checked_count, .. } => *checked_count,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdminAuditBreakReason {
PreviousHashMismatch,
MissingCurrentHash,
CurrentHashMismatch,
}
impl AdminAuditBreakReason {
pub fn as_str(self) -> &'static str {
match self {
Self::PreviousHashMismatch => "previous_hash_mismatch",
Self::MissingCurrentHash => "missing_current_hash",
Self::CurrentHashMismatch => "current_hash_mismatch",
}
}
}
pub fn compute_admin_audit_hash(
previous_hash: &str,
actor: &str,
operation: &str,
target: &str,
request_json: &serde_json::Value,
result: &str,
tenant_id: &str,
project_id: &str,
correlation_id: &str,
signer_key_id: &str,
external_anchor: &str,
) -> String {
use sha2::{Digest, Sha256};
let canonical = serde_json::json!({
"previous_hash": previous_hash,
"actor": actor,
"operation": operation,
"target": target,
"request_json": request_json,
"result": result,
"tenant_id": tenant_id,
"project_id": project_id,
"correlation_id": correlation_id,
"signer_key_id": signer_key_id,
"external_anchor": external_anchor,
});
let encoded = serde_json::to_vec(&canonical).unwrap_or_default();
format!("{:x}", Sha256::digest(encoded))
}
pub fn verify_admin_audit_chain_step(
row: &AdminAuditRow,
expected_previous_hash: &str,
checked_count: i64,
) -> Result<String, AdminAuditChainReport> {
if row.previous_hash != expected_previous_hash {
return Err(AdminAuditChainReport::Failed {
checked_count,
first_broken_audit_id: row.audit_id,
reason: AdminAuditBreakReason::PreviousHashMismatch,
expected_previous_hash: expected_previous_hash.to_string(),
actual_previous_hash: row.previous_hash.clone(),
expected_current_hash: String::new(),
actual_current_hash: String::new(),
});
}
if row.current_hash.is_empty() {
let expected_current_hash = compute_admin_audit_hash(
&row.previous_hash,
&row.actor,
&row.operation,
&row.target,
&row.request_json,
&row.result,
&row.tenant_id,
&row.project_id,
&row.correlation_id,
&row.signer_key_id,
&row.external_anchor,
);
return Err(AdminAuditChainReport::Failed {
checked_count,
first_broken_audit_id: row.audit_id,
reason: AdminAuditBreakReason::MissingCurrentHash,
expected_previous_hash: expected_previous_hash.to_string(),
actual_previous_hash: row.previous_hash.clone(),
expected_current_hash,
actual_current_hash: row.current_hash.clone(),
});
}
let expected_current_hash = compute_admin_audit_hash(
&row.previous_hash,
&row.actor,
&row.operation,
&row.target,
&row.request_json,
&row.result,
&row.tenant_id,
&row.project_id,
&row.correlation_id,
&row.signer_key_id,
&row.external_anchor,
);
if row.current_hash != expected_current_hash {
return Err(AdminAuditChainReport::Failed {
checked_count,
first_broken_audit_id: row.audit_id,
reason: AdminAuditBreakReason::CurrentHashMismatch,
expected_previous_hash: expected_previous_hash.to_string(),
actual_previous_hash: row.previous_hash.clone(),
expected_current_hash,
actual_current_hash: row.current_hash.clone(),
});
}
Ok(row.current_hash.clone())
}
#[async_trait]
pub trait AdminAuditStore: Send + Sync {
fn backend_label(&self) -> &'static str;
async fn ensure_admin_audit_tables(&self) -> SystemStoreResult<()>;
async fn latest_admin_audit_hash(&self) -> SystemStoreResult<String>;
async fn append_admin_audit(&self, entry: &AdminAuditInsert) -> SystemStoreResult<Uuid>;
async fn list_admin_audit(
&self,
filter: &AdminAuditListFilter,
) -> SystemStoreResult<Vec<AdminAuditRow>>;
async fn verify_admin_audit_chain(
&self,
limit: Option<i64>,
) -> SystemStoreResult<AdminAuditChainReport>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MigrationRunState {
DryRun,
Preflight,
Applying,
Verifying,
Completed,
Error,
DeadLetter,
}
impl MigrationRunState {
pub fn as_str(self) -> &'static str {
match self {
Self::DryRun => "DRY_RUN",
Self::Preflight => "PREFLIGHT",
Self::Applying => "APPLYING",
Self::Verifying => "VERIFYING",
Self::Completed => "COMPLETED",
Self::Error => "ERROR",
Self::DeadLetter => "DEAD_LETTER",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"DRY_RUN" => Some(Self::DryRun),
"PREFLIGHT" => Some(Self::Preflight),
"APPLYING" => Some(Self::Applying),
"VERIFYING" => Some(Self::Verifying),
"COMPLETED" => Some(Self::Completed),
"ERROR" => Some(Self::Error),
"DEAD_LETTER" => Some(Self::DeadLetter),
_ => None,
}
}
pub fn all() -> &'static [MigrationRunState] {
&[
Self::DryRun,
Self::Preflight,
Self::Applying,
Self::Verifying,
Self::Completed,
Self::Error,
Self::DeadLetter,
]
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Error | Self::DeadLetter)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum OpLedgerStatus {
Pending,
Applied,
Verified,
Skipped,
Failed,
RolledBack,
}
impl OpLedgerStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "PENDING",
Self::Applied => "APPLIED",
Self::Verified => "VERIFIED",
Self::Skipped => "SKIPPED",
Self::Failed => "FAILED",
Self::RolledBack => "ROLLED_BACK",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"PENDING" => Some(Self::Pending),
"APPLIED" => Some(Self::Applied),
"VERIFIED" => Some(Self::Verified),
"SKIPPED" => Some(Self::Skipped),
"FAILED" => Some(Self::Failed),
"ROLLED_BACK" => Some(Self::RolledBack),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationRunInsert {
pub project_id: String,
pub catalog_version: String,
pub operations_hash: String,
pub approval_token: String,
pub state: MigrationRunState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationOpInsert {
pub run_id: Uuid,
pub operation_index: i32,
pub backend: String,
pub resource_uri: String,
pub operation_kind: String,
pub status: OpLedgerStatus,
pub rollback_json: serde_json::Value,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationRunRow {
pub run_id: Uuid,
pub project_id: String,
pub catalog_version: String,
pub state: MigrationRunState,
pub operations_hash: String,
pub approval_token: String,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationOpRow {
pub id: i64,
pub run_id: Uuid,
pub operation_index: i32,
pub backend: String,
pub resource_uri: String,
pub operation_kind: String,
pub status: OpLedgerStatus,
pub rollback_json: serde_json::Value,
pub error: String,
pub applied_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MigrationRunsFilter {
pub project_id: Option<String>,
pub state: Option<MigrationRunState>,
pub catalog_version: Option<String>,
pub limit: i64,
pub offset: i64,
}
#[async_trait]
pub trait MigrationAuditStore: Send + Sync {
fn backend_label(&self) -> &'static str;
async fn ensure_migration_audit_tables(&self) -> SystemStoreResult<()>;
async fn start_migration_run(&self, run: &MigrationRunInsert) -> SystemStoreResult<Uuid>;
async fn record_migration_op(&self, op: &MigrationOpInsert) -> SystemStoreResult<i64>;
async fn finish_migration_run(
&self,
run_id: Uuid,
new_state: MigrationRunState,
error: &str,
) -> SystemStoreResult<()>;
async fn get_migration_run(&self, run_id: Uuid) -> SystemStoreResult<Option<MigrationRunRow>>;
async fn list_migration_ops(&self, run_id: Uuid) -> SystemStoreResult<Vec<MigrationOpRow>>;
async fn list_migration_runs(
&self,
filter: &MigrationRunsFilter,
) -> SystemStoreResult<Vec<MigrationRunRow>>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projection_status_tokens_are_pinned() {
assert_eq!(ProjectionTaskStatus::Pending.as_str(), "PENDING");
assert_eq!(ProjectionTaskStatus::InProgress.as_str(), "IN_PROGRESS");
assert_eq!(ProjectionTaskStatus::Completed.as_str(), "COMPLETED");
assert_eq!(ProjectionTaskStatus::Failed.as_str(), "FAILED");
assert_eq!(ProjectionTaskStatus::DeadLetter.as_str(), "DEAD_LETTER");
for s in [
ProjectionTaskStatus::Pending,
ProjectionTaskStatus::InProgress,
ProjectionTaskStatus::Completed,
ProjectionTaskStatus::Failed,
ProjectionTaskStatus::DeadLetter,
] {
assert_eq!(ProjectionTaskStatus::parse(s.as_str()), Some(s));
}
assert_eq!(ProjectionTaskStatus::parse("bogus"), None);
}
#[test]
fn projection_operation_tokens_are_pinned() {
assert_eq!(ProjectionOperation::Upsert.as_str(), "upsert");
assert_eq!(ProjectionOperation::Delete.as_str(), "delete");
assert_eq!(
ProjectionOperation::parse("upsert"),
Some(ProjectionOperation::Upsert)
);
assert_eq!(
ProjectionOperation::parse("delete"),
Some(ProjectionOperation::Delete)
);
assert_eq!(ProjectionOperation::parse("INSERT"), None);
}
#[test]
fn projection_claimability_pinned() {
assert!(ProjectionTaskStatus::Pending.is_claimable());
assert!(ProjectionTaskStatus::Failed.is_claimable());
assert!(!ProjectionTaskStatus::InProgress.is_claimable());
assert!(!ProjectionTaskStatus::Completed.is_claimable());
assert!(!ProjectionTaskStatus::DeadLetter.is_claimable());
assert!(ProjectionTaskStatus::Completed.is_terminal());
assert!(ProjectionTaskStatus::DeadLetter.is_terminal());
assert!(!ProjectionTaskStatus::Pending.is_terminal());
}
#[test]
fn projection_summary_arithmetic_is_pinned() {
let s = ProjectionTaskSummary {
pending: 5,
in_progress: 2,
completed: 100,
failed: 3,
dead_letter: 1,
};
assert_eq!(s.total(), 111);
assert_eq!(s.claimable(), 8);
}
#[test]
fn saga_status_tokens_are_pinned() {
assert_eq!(SagaStatus::Indeterminate.as_str(), "indeterminate");
assert_eq!(SagaStatus::InProgress.as_str(), "in_progress");
assert_eq!(SagaStatus::Pending.as_str(), "pending");
assert_eq!(SagaStatus::Committed.as_str(), "committed");
assert_eq!(SagaStatus::Compensated.as_str(), "compensated");
assert_eq!(SagaStatus::Failed.as_str(), "failed");
assert_eq!(SagaStatus::InDoubt.as_str(), "in_doubt");
assert_eq!(
SagaStatus::FailedCompensation.as_str(),
"failed_compensation"
);
assert_eq!(SagaStatus::ManualReview.as_str(), "manual_review");
for s in SagaStatus::all() {
assert_eq!(SagaStatus::parse(s.as_str()), Some(*s));
}
assert_eq!(SagaStatus::parse("garbage"), None);
assert_eq!(SagaStatus::all().len(), 9, "exactly 9 saga statuses");
}
#[test]
fn saga_recoverability_pinned() {
assert!(SagaStatus::Indeterminate.is_recoverable());
assert!(SagaStatus::InProgress.is_recoverable());
assert!(SagaStatus::InDoubt.is_recoverable());
assert!(!SagaStatus::Pending.is_recoverable());
assert!(!SagaStatus::Committed.is_recoverable());
assert!(SagaStatus::Committed.is_terminal());
assert!(SagaStatus::Compensated.is_terminal());
assert!(SagaStatus::ManualReview.is_terminal());
assert!(!SagaStatus::FailedCompensation.is_terminal());
}
#[test]
fn compensation_status_tokens_are_pinned() {
assert_eq!(CompensationStatus::None.as_str(), "none");
assert_eq!(CompensationStatus::Completed.as_str(), "completed");
assert_eq!(CompensationStatus::ManualReview.as_str(), "manual_review");
assert_eq!(
CompensationStatus::RetryRequested.as_str(),
"retry_requested"
);
for token in ["none", "completed", "manual_review", "retry_requested"] {
assert_eq!(
CompensationStatus::parse(token).map(|s| s.as_str()),
Some(token)
);
}
}
#[test]
fn saga_summary_arithmetic_is_pinned() {
let s = SagaSummary {
indeterminate: 1,
in_progress: 2,
pending: 3,
committed: 10,
compensated: 5,
failed: 1,
in_doubt: 6,
failed_compensation: 2,
manual_review: 4,
};
assert_eq!(s.total(), 34);
assert_eq!(s.recoverable(), 1 + 2 + 6 + 2);
}
fn sample_audit_row(audit_id: Uuid, previous_hash: &str) -> AdminAuditRow {
let req = serde_json::json!({"target": "catalog"});
let current = compute_admin_audit_hash(
previous_hash,
"operator-1",
"ActivateCatalog",
"project-alpha",
&req,
"ok",
"tenant-1",
"project-alpha",
"corr-1",
"default",
"",
);
AdminAuditRow {
audit_id,
actor: "operator-1".to_string(),
operation: "ActivateCatalog".to_string(),
target: "project-alpha".to_string(),
request_json: req,
result: "ok".to_string(),
tenant_id: "tenant-1".to_string(),
project_id: "project-alpha".to_string(),
correlation_id: "corr-1".to_string(),
previous_hash: previous_hash.to_string(),
current_hash: current,
signer_key_id: "default".to_string(),
external_anchor: String::new(),
created_at: Utc::now(),
}
}
#[test]
fn admin_audit_hash_is_deterministic_and_collision_resistant() {
let req = serde_json::json!({"x": 1});
let h1 = compute_admin_audit_hash(
"", "actor", "Op", "t", &req, "ok", "tn", "pj", "co", "k", "",
);
let h2 = compute_admin_audit_hash(
"", "actor", "Op", "t", &req, "ok", "tn", "pj", "co", "k", "",
);
assert_eq!(h1, h2, "same inputs → same hash");
assert_eq!(h1.len(), 64, "SHA-256 hex is 64 chars");
let h_changed = compute_admin_audit_hash(
"",
"actor",
"Op",
"t",
&req,
"ok",
"tn",
"pj",
"co",
"DIFFERENT",
"",
);
assert_ne!(h1, h_changed);
}
#[test]
fn admin_audit_chain_step_accepts_valid_row() {
let row = sample_audit_row(Uuid::new_v4(), "");
let next_previous =
verify_admin_audit_chain_step(&row, "", 0).expect("first row links the empty seed");
assert_eq!(next_previous, row.current_hash);
}
#[test]
fn admin_audit_chain_step_rejects_previous_hash_mismatch() {
let mut row = sample_audit_row(Uuid::new_v4(), "abcd");
row.previous_hash = "wrong".to_string();
let err = verify_admin_audit_chain_step(&row, "expected-hash", 5).expect_err("must reject");
match err {
AdminAuditChainReport::Failed {
reason,
checked_count,
expected_previous_hash,
actual_previous_hash,
..
} => {
assert_eq!(reason, AdminAuditBreakReason::PreviousHashMismatch);
assert_eq!(checked_count, 5);
assert_eq!(expected_previous_hash, "expected-hash");
assert_eq!(actual_previous_hash, "wrong");
}
other => panic!("expected Failed, got: {other:?}"),
}
}
#[test]
fn admin_audit_chain_step_rejects_missing_current_hash() {
let mut row = sample_audit_row(Uuid::new_v4(), "");
row.current_hash = String::new();
let err = verify_admin_audit_chain_step(&row, "", 0).expect_err("must reject");
assert!(matches!(
err,
AdminAuditChainReport::Failed {
reason: AdminAuditBreakReason::MissingCurrentHash,
..
}
));
}
#[test]
fn admin_audit_chain_step_rejects_tampered_body() {
let mut row = sample_audit_row(Uuid::new_v4(), "");
row.operation = "MaliciouslyAlteredOp".to_string();
let err = verify_admin_audit_chain_step(&row, "", 0).expect_err("must reject");
match err {
AdminAuditChainReport::Failed {
reason,
expected_current_hash,
actual_current_hash,
..
} => {
assert_eq!(reason, AdminAuditBreakReason::CurrentHashMismatch);
assert_ne!(expected_current_hash, actual_current_hash);
}
other => panic!("expected Failed, got: {other:?}"),
}
}
#[test]
fn admin_audit_break_reason_tokens_pinned() {
assert_eq!(
AdminAuditBreakReason::PreviousHashMismatch.as_str(),
"previous_hash_mismatch"
);
assert_eq!(
AdminAuditBreakReason::MissingCurrentHash.as_str(),
"missing_current_hash"
);
assert_eq!(
AdminAuditBreakReason::CurrentHashMismatch.as_str(),
"current_hash_mismatch"
);
}
#[test]
fn admin_audit_chain_report_predicates() {
let passed = AdminAuditChainReport::Passed {
checked_count: 10,
last_hash: "abc".to_string(),
};
let failed = AdminAuditChainReport::Failed {
checked_count: 5,
first_broken_audit_id: Uuid::nil(),
reason: AdminAuditBreakReason::PreviousHashMismatch,
expected_previous_hash: String::new(),
actual_previous_hash: String::new(),
expected_current_hash: String::new(),
actual_current_hash: String::new(),
};
assert!(passed.is_passed());
assert!(!failed.is_passed());
assert_eq!(passed.checked_count(), 10);
assert_eq!(failed.checked_count(), 5);
}
#[test]
fn migration_run_state_tokens_pinned() {
assert_eq!(MigrationRunState::DryRun.as_str(), "DRY_RUN");
assert_eq!(MigrationRunState::Preflight.as_str(), "PREFLIGHT");
assert_eq!(MigrationRunState::Applying.as_str(), "APPLYING");
assert_eq!(MigrationRunState::Verifying.as_str(), "VERIFYING");
assert_eq!(MigrationRunState::Completed.as_str(), "COMPLETED");
assert_eq!(MigrationRunState::Error.as_str(), "ERROR");
assert_eq!(MigrationRunState::DeadLetter.as_str(), "DEAD_LETTER");
for s in MigrationRunState::all() {
assert_eq!(MigrationRunState::parse(s.as_str()), Some(*s));
}
assert_eq!(MigrationRunState::parse("garbage"), None);
assert_eq!(MigrationRunState::all().len(), 7);
assert!(MigrationRunState::Completed.is_terminal());
assert!(MigrationRunState::Error.is_terminal());
assert!(MigrationRunState::DeadLetter.is_terminal());
assert!(!MigrationRunState::Applying.is_terminal());
assert!(!MigrationRunState::DryRun.is_terminal());
}
#[test]
fn op_ledger_status_tokens_pinned() {
for (s, token) in [
(OpLedgerStatus::Pending, "PENDING"),
(OpLedgerStatus::Applied, "APPLIED"),
(OpLedgerStatus::Verified, "VERIFIED"),
(OpLedgerStatus::Skipped, "SKIPPED"),
(OpLedgerStatus::Failed, "FAILED"),
(OpLedgerStatus::RolledBack, "ROLLED_BACK"),
] {
assert_eq!(s.as_str(), token);
assert_eq!(OpLedgerStatus::parse(token), Some(s));
}
assert_eq!(OpLedgerStatus::parse("none"), None);
}
#[test]
fn system_store_error_display_is_useful() {
let big_sql = format!("SELECT {} FROM t", "x".repeat(500));
let e = SystemStoreError::query("postgres", big_sql.clone(), "syntax error at $1");
let s = format!("{e}");
assert!(s.contains("postgres"));
assert!(s.contains("syntax error at $1"));
assert!(!s.contains(&"x".repeat(200)));
assert!(s.len() < big_sql.len(), "error must not embed full SQL");
}
}