keepsake-sqlx 0.6.0

SQLx adapter for keepsake lifecycle storage
Documentation
#![allow(missing_docs, clippy::missing_panics_doc)]

use chrono::{DateTime, Utc};
use keepsake::{
    ActorRef, ApplyKeepsake, CommandContext, ExpiryPolicy, FulfillmentPolicy, RelationDefinition,
    RelationKey, SubjectRef,
};
use keepsake_sqlx::RepositoryError;
use uuid::Uuid;

pub type TestResult<T> = Result<T, TestError>;

#[derive(Debug, thiserror::Error)]
pub enum TestError {
    #[error(transparent)]
    Chrono(#[from] chrono::ParseError),
    #[error(transparent)]
    Keepsake(#[from] keepsake::KeepsakeError),
    #[error(transparent)]
    Repository(#[from] RepositoryError),
    #[error(transparent)]
    SerdeJson(#[from] serde_json::Error),
    #[error(transparent)]
    Sqlx(#[from] sqlx::Error),
    #[error(transparent)]
    Env(#[from] std::env::VarError),
}

#[async_trait::async_trait]
pub trait BackendHarness {
    const BACKEND: &'static str;

    type Pool: Send + Sync;
    type Repo: Send + Sync;

    async fn repo() -> TestResult<(Self::Repo, Self::Pool)>;
    async fn backend_marker(pool: &Self::Pool) -> Result<String, sqlx::Error>;
    async fn upsert_relation(
        repo: &Self::Repo,
        relation: &RelationDefinition,
        at: DateTime<Utc>,
    ) -> Result<RelationDefinition, RepositoryError>;
    async fn apply(
        repo: &Self::Repo,
        command: &ApplyKeepsake,
    ) -> Result<keepsake_sqlx::AppliedKeepsake, RepositoryError>;
    async fn active_relations_for_subject(
        repo: &Self::Repo,
        subject: &SubjectRef,
    ) -> Result<Vec<keepsake_sqlx::ActiveRelation>, RepositoryError>;
    async fn active_for_subject(
        repo: &Self::Repo,
        subject: &SubjectRef,
    ) -> Result<Vec<keepsake::Keepsake>, RepositoryError>;
    async fn expire_due_timed(
        repo: &Self::Repo,
        now: DateTime<Utc>,
        limit: i64,
    ) -> Result<u64, RepositoryError>;
    async fn upsert_counter_projection(
        repo: &Self::Repo,
        keepsake_id: Uuid,
        key: &str,
        value: i64,
        observed_at: DateTime<Utc>,
    ) -> Result<(), RepositoryError>;
    async fn set_relation_enabled(
        repo: &Self::Repo,
        relation_id: Uuid,
        enabled: bool,
        at: DateTime<Utc>,
    ) -> Result<bool, RepositoryError>;
    async fn expire_due_fulfilled(
        repo: &Self::Repo,
        now: DateTime<Utc>,
        limit: i64,
    ) -> Result<u64, RepositoryError>;
}

pub fn ts(value: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
    DateTime::parse_from_rfc3339(value).map(|timestamp| timestamp.with_timezone(&Utc))
}

fn context() -> TestResult<CommandContext> {
    Ok(CommandContext::new(ActorRef::new("test", "worker")?))
}

pub async fn upsert_relation<H>(
    repo: &H::Repo,
    expiry: ExpiryPolicy,
) -> TestResult<RelationDefinition>
where
    H: BackendHarness,
{
    let relation = RelationDefinition::enabled(
        Uuid::now_v7(),
        RelationKey::new("tag", format!("{}-{}", H::BACKEND, Uuid::now_v7()))?,
        expiry,
    )?;
    Ok(H::upsert_relation(repo, &relation, ts("2026-01-01T00:00:00Z")?).await?)
}

pub async fn migration_initializes_backend_marker<H>() -> TestResult<()>
where
    H: BackendHarness,
{
    let (_repo, pool) = H::repo().await?;
    let marker = H::backend_marker(&pool).await?;

    assert_eq!(marker, H::BACKEND);
    Ok(())
}

pub async fn apply_duplicate_and_active_read<H>() -> TestResult<()>
where
    H: BackendHarness,
{
    let (repo, _pool) = H::repo().await?;
    let relation = upsert_relation::<H>(&repo, ExpiryPolicy::ManualOnly).await?;
    let subject = SubjectRef::new("account", format!("{}_acct_123", H::BACKEND))?;
    let command = ApplyKeepsake::new(
        subject.clone(),
        relation.id,
        ts("2026-01-01T00:01:00Z")?,
        context()?,
    );

    let first = H::apply(&repo, &command).await?;
    let second = H::apply(
        &repo,
        &ApplyKeepsake::new(
            subject.clone(),
            relation.id,
            ts("2026-01-01T00:02:00Z")?,
            context()?,
        ),
    )
    .await?;
    let active = H::active_relations_for_subject(&repo, &subject).await?;

    assert!(!first.duplicate_prevented);
    assert!(second.duplicate_prevented);
    assert_eq!(first.keepsake.id(), second.keepsake.id());
    assert_eq!(active.len(), 1);
    assert_eq!(active[0].relation().id, relation.id);
    Ok(())
}

pub async fn timed_expiry_expires_due_keepsake<H>() -> TestResult<()>
where
    H: BackendHarness,
{
    let (repo, _pool) = H::repo().await?;
    let relation = upsert_relation::<H>(
        &repo,
        ExpiryPolicy::At {
            timestamp: ts("2026-01-01T00:02:00Z")?,
        },
    )
    .await?;
    let subject = SubjectRef::new("account", format!("{}_acct_expiring", H::BACKEND))?;
    let applied = H::apply(
        &repo,
        &ApplyKeepsake::new(
            subject,
            relation.id,
            ts("2026-01-01T00:01:00Z")?,
            context()?,
        ),
    )
    .await?;

    let expired = H::expire_due_timed(&repo, ts("2026-01-01T00:02:00Z")?, 10).await?;
    let keepsake = H::active_for_subject(&repo, applied.keepsake.subject()).await?;

    assert_eq!(expired, 1);
    assert!(keepsake.is_empty());
    Ok(())
}

pub async fn fulfilled_expiry_uses_counter_snapshot<H>() -> TestResult<()>
where
    H: BackendHarness,
{
    let (repo, _pool) = H::repo().await?;
    let relation = upsert_relation::<H>(
        &repo,
        ExpiryPolicy::WhenFulfilled {
            policy: FulfillmentPolicy::CounterAtLeast {
                key: "steps".to_owned(),
                threshold: 3,
            },
        },
    )
    .await?;
    let subject = SubjectRef::new("account", format!("{}_acct_steps", H::BACKEND))?;
    let applied = H::apply(
        &repo,
        &ApplyKeepsake::new(
            subject,
            relation.id,
            ts("2026-01-01T00:01:00Z")?,
            context()?,
        ),
    )
    .await?;

    assert_eq!(
        H::expire_due_fulfilled(&repo, ts("2026-01-01T00:02:00Z")?, 10).await?,
        0
    );
    H::upsert_counter_projection(
        &repo,
        applied.keepsake.id(),
        "steps",
        3,
        ts("2026-01-01T00:03:00Z")?,
    )
    .await?;

    assert_eq!(
        H::expire_due_fulfilled(&repo, ts("2026-01-01T00:04:00Z")?, 10).await?,
        1
    );
    Ok(())
}

pub async fn fulfilled_expiry_skips_disabled_relations_before_limit<H>() -> TestResult<()>
where
    H: BackendHarness,
{
    let (repo, _pool) = H::repo().await?;
    let disabled_relation = RelationDefinition::enabled(
        Uuid::from_u128(1),
        RelationKey::new("tag", format!("{}-disabled-first", H::BACKEND))?,
        ExpiryPolicy::WhenFulfilled {
            policy: FulfillmentPolicy::CounterAtLeast {
                key: "steps".to_owned(),
                threshold: 3,
            },
        },
    )?;
    let enabled_relation = RelationDefinition::enabled(
        Uuid::from_u128(2),
        RelationKey::new("tag", format!("{}-enabled-second", H::BACKEND))?,
        ExpiryPolicy::WhenFulfilled {
            policy: FulfillmentPolicy::CounterAtLeast {
                key: "steps".to_owned(),
                threshold: 3,
            },
        },
    )?;
    let disabled_relation =
        H::upsert_relation(&repo, &disabled_relation, ts("2026-01-01T00:00:00Z")?).await?;
    let enabled_relation =
        H::upsert_relation(&repo, &enabled_relation, ts("2026-01-01T00:00:00Z")?).await?;

    let disabled_subject = SubjectRef::new("account", format!("{}_disabled_first", H::BACKEND))?;
    let enabled_subject = SubjectRef::new("account", format!("{}_enabled_second", H::BACKEND))?;
    let disabled = H::apply(
        &repo,
        &ApplyKeepsake::new(
            disabled_subject.clone(),
            disabled_relation.id,
            ts("2026-01-01T00:02:00Z")?,
            context()?,
        ),
    )
    .await?;
    let enabled = H::apply(
        &repo,
        &ApplyKeepsake::new(
            enabled_subject.clone(),
            enabled_relation.id,
            ts("2026-01-01T00:02:00Z")?,
            context()?,
        ),
    )
    .await?;
    assert!(
        H::set_relation_enabled(
            &repo,
            disabled_relation.id,
            false,
            ts("2026-01-01T00:03:00Z")?,
        )
        .await?
    );
    for keepsake_id in [disabled.keepsake.id(), enabled.keepsake.id()] {
        H::upsert_counter_projection(&repo, keepsake_id, "steps", 3, ts("2026-01-01T00:04:00Z")?)
            .await?;
    }

    assert_eq!(
        H::expire_due_fulfilled(&repo, ts("2026-01-01T00:05:00Z")?, 1).await?,
        1
    );
    assert_eq!(
        H::active_for_subject(&repo, &disabled_subject).await?.len(),
        1
    );
    assert!(
        H::active_for_subject(&repo, &enabled_subject)
            .await?
            .is_empty()
    );
    Ok(())
}

pub async fn fulfilled_expiry_skips_unfulfilled_relations_before_limit<H>() -> TestResult<()>
where
    H: BackendHarness,
{
    let (repo, _pool) = H::repo().await?;
    let unfulfilled_relation = RelationDefinition::enabled(
        Uuid::from_u128(1),
        RelationKey::new("tag", format!("{}-unfulfilled-first", H::BACKEND))?,
        ExpiryPolicy::WhenFulfilled {
            policy: FulfillmentPolicy::CounterAtLeast {
                key: "steps".to_owned(),
                threshold: 3,
            },
        },
    )?;
    let fulfilled_relation = RelationDefinition::enabled(
        Uuid::from_u128(2),
        RelationKey::new("tag", format!("{}-fulfilled-second", H::BACKEND))?,
        ExpiryPolicy::WhenFulfilled {
            policy: FulfillmentPolicy::CounterAtLeast {
                key: "steps".to_owned(),
                threshold: 3,
            },
        },
    )?;
    let unfulfilled_relation =
        H::upsert_relation(&repo, &unfulfilled_relation, ts("2026-01-01T00:00:00Z")?).await?;
    let fulfilled_relation =
        H::upsert_relation(&repo, &fulfilled_relation, ts("2026-01-01T00:00:00Z")?).await?;

    let unfulfilled_subject =
        SubjectRef::new("account", format!("{}_unfulfilled_first", H::BACKEND))?;
    let fulfilled_subject = SubjectRef::new("account", format!("{}_fulfilled_second", H::BACKEND))?;
    let _unfulfilled = H::apply(
        &repo,
        &ApplyKeepsake::new(
            unfulfilled_subject.clone(),
            unfulfilled_relation.id,
            ts("2026-01-01T00:02:00Z")?,
            context()?,
        ),
    )
    .await?;
    let fulfilled = H::apply(
        &repo,
        &ApplyKeepsake::new(
            fulfilled_subject.clone(),
            fulfilled_relation.id,
            ts("2026-01-01T00:02:00Z")?,
            context()?,
        ),
    )
    .await?;
    H::upsert_counter_projection(
        &repo,
        fulfilled.keepsake.id(),
        "steps",
        3,
        ts("2026-01-01T00:03:00Z")?,
    )
    .await?;

    assert_eq!(
        H::expire_due_fulfilled(&repo, ts("2026-01-01T00:04:00Z")?, 1).await?,
        1
    );
    assert_eq!(
        H::active_for_subject(&repo, &unfulfilled_subject)
            .await?
            .len(),
        1
    );
    assert!(
        H::active_for_subject(&repo, &fulfilled_subject)
            .await?
            .is_empty()
    );
    Ok(())
}