mod helpers;
use derive_builder::Builder;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use es_entity::*;
es_entity::entity_id! { SubscriberId }
#[derive(EsEvent, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[es_event(id = "SubscriberId")]
pub enum SubscriberEvent {
Initialized {
id: SubscriberId,
email: Forgettable<String>,
plan: String,
},
PlanChanged {
plan: String,
},
EmailUpdated {
email: Forgettable<String>,
},
Forgot {},
}
#[derive(EsEntity, Builder)]
#[builder(pattern = "owned", build_fn(error = "EntityHydrationError"))]
pub struct Subscriber {
pub id: SubscriberId,
pub email: Forgettable<String>,
pub plan: String,
events: EntityEvents<SubscriberEvent>,
}
impl Subscriber {
pub fn change_plan(&mut self, plan: impl Into<String>) -> Idempotent<()> {
let plan = plan.into();
self.plan = plan.clone();
self.events.push(SubscriberEvent::PlanChanged { plan });
Idempotent::Executed(())
}
pub fn update_email(&mut self, email: impl Into<String>) -> Idempotent<()> {
let email = email.into();
self.email = Forgettable::new(email.clone());
self.events.push(SubscriberEvent::EmailUpdated {
email: Forgettable::new(email),
});
Idempotent::Executed(())
}
pub fn record_erasure(&mut self) {
self.events.push(SubscriberEvent::Forgot {});
}
}
impl TryFromEvents<SubscriberEvent> for Subscriber {
fn try_from_events(
events: EntityEvents<SubscriberEvent>,
) -> Result<Self, EntityHydrationError> {
let mut builder = SubscriberBuilder::default();
for event in events.iter_all() {
match event {
SubscriberEvent::Initialized { id, email, plan } => {
builder = builder.id(*id).email(email.clone()).plan(plan.clone());
}
SubscriberEvent::PlanChanged { plan } => {
builder = builder.plan(plan.clone());
}
SubscriberEvent::EmailUpdated { email } => {
builder = builder.email(email.clone());
}
SubscriberEvent::Forgot { .. } => {}
}
}
builder.events(events).build()
}
}
#[derive(Debug, Builder)]
pub struct NewSubscriber {
#[builder(setter(into))]
pub id: SubscriberId,
#[builder(setter(into))]
pub email: String,
#[builder(setter(into))]
pub plan: String,
}
impl NewSubscriber {
pub fn builder() -> NewSubscriberBuilder {
NewSubscriberBuilder::default()
}
}
impl IntoEvents<SubscriberEvent> for NewSubscriber {
fn into_events(self) -> EntityEvents<SubscriberEvent> {
EntityEvents::init(
self.id,
[SubscriberEvent::Initialized {
id: self.id,
email: Forgettable::new(self.email),
plan: self.plan,
}],
)
}
}
#[derive(EsRepo, Debug)]
#[es_repo(
entity = "Subscriber",
forgettable,
delete = "soft",
columns(email(ty = "Forgettable<String>", list_by), plan(ty = "String"))
)]
pub struct Subscribers {
pool: PgPool,
}
impl Subscribers {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
async fn new_subscriber(subscribers: &Subscribers) -> anyhow::Result<(Subscriber, String)> {
let id = SubscriberId::new();
let email = format!("user-{id}@example.com");
let new = NewSubscriber::builder()
.id(id)
.email(email.clone())
.plan("pro")
.build()
.unwrap();
let subscriber = subscribers.create(new).await?;
Ok((subscriber, email))
}
#[tokio::test]
async fn forgettable_column_is_queryable_while_live() -> anyhow::Result<()> {
let pool = helpers::init_pool().await?;
let subscribers = Subscribers::new(pool);
let (subscriber, email) = new_subscriber(&subscribers).await?;
let found = subscribers.find_by_email(&email).await?;
assert_eq!(found.id, subscriber.id);
assert_eq!(found.email.value().map(|v| v.clone()), Some(email));
Ok(())
}
#[tokio::test]
async fn forget_nulls_the_index_column() -> anyhow::Result<()> {
let pool = helpers::init_pool().await?;
let subscribers = Subscribers::new(pool.clone());
let (subscriber, email) = new_subscriber(&subscribers).await?;
let id = subscriber.id;
let subscriber = subscribers.forget(subscriber).await?;
assert!(subscriber.email.is_forgotten());
let row = sqlx::query!(
"SELECT email, plan FROM subscribers WHERE id = $1",
id as SubscriberId
)
.fetch_one(&pool)
.await?;
assert_eq!(row.email, None);
assert_eq!(row.plan, "pro");
let payloads = sqlx::query!(
"SELECT COUNT(*) as count FROM subscribers_forgettable_payloads WHERE entity_id = $1",
id as SubscriberId
)
.fetch_one(&pool)
.await?;
assert_eq!(payloads.count, Some(0));
let refound = subscribers.maybe_find_by_email(&email).await?;
assert!(refound.is_none());
Ok(())
}
#[tokio::test]
async fn delete_with_pending_forgettable_events_leaves_the_pending_payload() -> anyhow::Result<()> {
let pool = helpers::init_pool().await?;
let subscribers = Subscribers::new(pool.clone());
let (mut subscriber, _email) = new_subscriber(&subscribers).await?;
let id = subscriber.id;
let _ = subscriber.update_email(format!("pending-{id}@example.com"));
subscribers.delete(subscriber).await?;
let payloads = sqlx::query!(
r#"SELECT COUNT(*) AS "count!" FROM subscribers_forgettable_payloads
WHERE entity_id = $1"#,
id as SubscriberId
)
.fetch_one(&pool)
.await?;
assert_eq!(
payloads.count, 1,
"the pending event's payload survives base soft-delete ordering"
);
let row = sqlx::query!(
r#"SELECT event->>'email' IS NULL AS "email_is_null!" FROM subscriber_events
WHERE id = $1 AND event_type = 'email_updated'"#,
id as SubscriberId
)
.fetch_one(&pool)
.await?;
assert!(row.email_is_null);
Ok(())
}
#[tokio::test]
async fn staged_erasure_event_fences_stale_writers_on_delete() -> anyhow::Result<()> {
let pool = helpers::init_pool().await?;
let subscribers = Subscribers::new(pool.clone());
let (mut subscriber, email) = new_subscriber(&subscribers).await?;
let id = subscriber.id;
let mut stale = subscribers.find_by_id(id).await?;
subscriber.record_erasure();
subscribers.delete(subscriber).await?;
let _ = stale.change_plan("basic");
let err = subscribers
.update(&mut stale)
.await
.expect_err("stale update after fenced delete must fail");
assert!(
err.was_concurrent_modification(),
"expected ConcurrentModification, got: {err}"
);
let row = sqlx::query!(
"SELECT email, deleted FROM subscribers WHERE id = $1",
id as SubscriberId
)
.fetch_one(&pool)
.await?;
assert!(row.deleted);
assert_eq!(row.email, None);
let _ = email;
Ok(())
}
#[tokio::test]
async fn soft_delete_auto_forgets_the_index_column() -> anyhow::Result<()> {
let pool = helpers::init_pool().await?;
let subscribers = Subscribers::new(pool.clone());
let (subscriber, _email) = new_subscriber(&subscribers).await?;
let id = subscriber.id;
subscribers.delete(subscriber).await?;
let row = sqlx::query!(
"SELECT email, plan, deleted FROM subscribers WHERE id = $1",
id as SubscriberId
)
.fetch_one(&pool)
.await?;
assert!(row.deleted);
assert_eq!(row.email, None);
assert_eq!(row.plan, "pro");
Ok(())
}