mod entities;
mod helpers;
use sqlx::PgPool;
use std::sync::{
Mutex,
atomic::{AtomicBool, Ordering},
};
use entities::customer::*;
use es_entity::*;
#[derive(Debug)]
pub struct CustomerPublishError(String);
impl std::fmt::Display for CustomerPublishError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "publish failed: {}", self.0)
}
}
impl std::error::Error for CustomerPublishError {}
#[derive(Debug, Clone)]
pub struct HookCall {
pub event_types: Vec<String>,
pub entity_name: String,
pub saw_raw_name: bool,
pub payload_rows_at_hook_time: i64,
}
#[derive(EsRepo, Debug)]
#[es_repo(
entity = "Customer",
forgettable,
columns(email(ty = "String")),
post_persist_hook(method = "publish", error = "CustomerPublishError")
)]
pub struct CustomersWithHook {
pool: PgPool,
calls: Mutex<Vec<HookCall>>,
fail_on_forgot: AtomicBool,
}
impl CustomersWithHook {
pub fn new(pool: PgPool) -> Self {
Self {
pool,
calls: Mutex::new(Vec::new()),
fail_on_forgot: AtomicBool::new(false),
}
}
pub fn calls(&self) -> Vec<HookCall> {
self.calls.lock().unwrap().clone()
}
async fn publish<OP: es_entity::AtomicOperation>(
&self,
op: &mut OP,
entity: &Customer,
new_events: es_entity::events::LastPersisted<'_, CustomerEvent>,
) -> Result<(), CustomerPublishError> {
let events: Vec<_> = new_events.collect();
let event_types: Vec<String> = events
.iter()
.map(|e| match &e.event {
CustomerEvent::Initialized { .. } => "initialized".to_string(),
CustomerEvent::NameUpdated { .. } => "name_updated".to_string(),
CustomerEvent::EmailUpdated { .. } => "email_updated".to_string(),
CustomerEvent::Forgot { .. } => "forgot".to_string(),
})
.collect();
let saw_raw_name = events.iter().any(|e| match &e.event {
CustomerEvent::NameUpdated { name } => name.value().is_some(),
_ => false,
});
let id = entity.id;
let payload_rows_at_hook_time = sqlx::query!(
r#"SELECT COUNT(*) AS "count!" FROM customers_forgettable_payloads
WHERE entity_id = $1"#,
id as CustomerId
)
.fetch_one(op.as_executor())
.await
.map_err(|e| CustomerPublishError(e.to_string()))?
.count;
if self.fail_on_forgot.load(Ordering::SeqCst) && event_types.iter().any(|t| t == "forgot") {
return Err(CustomerPublishError(format!(
"publisher rejected forgot event for {id}"
)));
}
self.calls.lock().unwrap().push(HookCall {
event_types,
entity_name: entity.name.clone(),
saw_raw_name,
payload_rows_at_hook_time,
});
Ok(())
}
}
#[tokio::test]
async fn forget_runs_post_persist_hook_for_staged_erasure_event() -> anyhow::Result<()> {
let pool = helpers::init_pool().await?;
let customers = CustomersWithHook::new(pool);
let id = CustomerId::new();
let new_customer = NewCustomer::builder()
.id(id)
.name("Hooked Hannah")
.email("hooked@example.com")
.build()
.unwrap();
let mut customer = customers.create(new_customer).await?;
assert_eq!(customers.calls().len(), 1, "create fires the hook once");
customer.record_erasure();
customers.forget(customer).await?;
let calls = customers.calls();
assert_eq!(calls.len(), 2, "forget fires the hook exactly once more");
let forget_call = &calls[1];
assert_eq!(forget_call.event_types, vec!["forgot"]);
assert_eq!(forget_call.entity_name, "[forgotten]");
assert_eq!(forget_call.payload_rows_at_hook_time, 0);
Ok(())
}
#[tokio::test]
async fn forget_without_staged_events_does_not_run_hook() -> anyhow::Result<()> {
let pool = helpers::init_pool().await?;
let customers = CustomersWithHook::new(pool);
let id = CustomerId::new();
let new_customer = NewCustomer::builder()
.id(id)
.name("Quiet Quinn")
.email("quiet@example.com")
.build()
.unwrap();
let customer = customers.create(new_customer).await?;
assert_eq!(customers.calls().len(), 1);
customers.forget(customer).await?;
assert_eq!(customers.calls().len(), 1, "hook must not fire");
Ok(())
}
#[tokio::test]
async fn hook_sees_forgotten_payloads_for_staged_pii_events() -> anyhow::Result<()> {
let pool = helpers::init_pool().await?;
let customers = CustomersWithHook::new(pool);
let id = CustomerId::new();
let new_customer = NewCustomer::builder()
.id(id)
.name("Leaky Lou")
.email("leaky@example.com")
.build()
.unwrap();
let mut customer = customers.create(new_customer).await?;
let _ = customer.update_name("Unpersisted Raw Name");
customer.record_erasure();
customers.forget(customer).await?;
let calls = customers.calls();
assert_eq!(calls.len(), 2);
let forget_call = &calls[1];
assert_eq!(forget_call.event_types, vec!["name_updated", "forgot"]);
assert!(
!forget_call.saw_raw_name,
"hook must never observe raw PII during an erasure"
);
assert_eq!(forget_call.entity_name, "[forgotten]");
Ok(())
}
#[tokio::test]
async fn hook_error_rolls_back_the_entire_erasure() -> anyhow::Result<()> {
let pool = helpers::init_pool().await?;
let customers = CustomersWithHook::new(pool.clone());
let id = CustomerId::new();
let new_customer = NewCustomer::builder()
.id(id)
.name("Rollback Rae")
.email("rollback@example.com")
.build()
.unwrap();
let mut customer = customers.create(new_customer).await?;
customers.fail_on_forgot.store(true, Ordering::SeqCst);
customer.record_erasure();
let err = match customers.forget(customer).await {
Err(e) => e,
Ok(_) => panic!("hook failure must fail the forget"),
};
match err {
CustomerForgetError::PostPersistHookError(inner) => {
assert!(inner.to_string().contains("rejected forgot event"));
}
e => panic!("expected PostPersistHookError, got: {e}"),
}
let payloads = sqlx::query!(
r#"SELECT COUNT(*) AS "count!" FROM customers_forgettable_payloads
WHERE entity_id = $1"#,
id as CustomerId
)
.fetch_one(&pool)
.await?;
assert_eq!(payloads.count, 1);
let reloaded = customers.find_by_id(id).await?;
assert_eq!(reloaded.name, "Rollback Rae");
let forgot_events = sqlx::query!(
r#"SELECT COUNT(*) AS "count!" FROM customer_events
WHERE id = $1 AND event_type = 'forgot'"#,
id as CustomerId
)
.fetch_one(&pool)
.await?;
assert_eq!(forgot_events.count, 0);
customers.fail_on_forgot.store(false, Ordering::SeqCst);
let mut customer = customers.find_by_id(id).await?;
customer.record_erasure();
let customer = customers.forget(customer).await?;
assert_eq!(customer.name, "[forgotten]");
customers.verify_forgotten(id).await?;
Ok(())
}
#[tokio::test]
async fn stale_writer_pii_is_fenced_and_leaves_no_trace_after_forget() -> anyhow::Result<()> {
let pool = helpers::init_pool().await?;
let customers = CustomersWithHook::new(pool.clone());
let id = CustomerId::new();
let new_customer = NewCustomer::builder()
.id(id)
.name("Fenced Fran")
.email("fenced@example.com")
.build()
.unwrap();
customers.create(new_customer).await?;
let mut stale = customers.find_by_id(id).await?;
let mut fresh = customers.find_by_id(id).await?;
fresh.record_erasure();
customers.forget(fresh).await?;
let _ = stale.update_name("Resurrected PII");
let err = customers
.update(&mut stale)
.await
.expect_err("stale PII write after forget must fail");
assert!(err.was_concurrent_modification());
let calls = customers.calls();
assert_eq!(
calls.len(),
2,
"create + forget only — no publish for the rejected write"
);
customers.verify_forgotten(id).await?;
Ok(())
}