keepsake-sqlx 1.0.0

SQLx adapter for keepsake lifecycle storage
Documentation
use chrono::DateTime;
use keepsake::SubjectRef;
use sqlx::postgres::PgPoolOptions;

use super::support::parse_state;
use super::*;

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

#[derive(Debug, thiserror::Error)]
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),
}

#[tokio::test]
async fn timestamp_scoped_repository_reuses_explicit_timestamp() -> Result<(), TestError> {
    let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/keepsake")?;
    let repo = KeepsakeRepository::new(pool);
    let at = ts("2026-01-02T00:00:00Z")?;
    let timed_repo = repo.at(at);

    assert_eq!(timed_repo.timestamp(), at);
    Ok(())
}

#[tokio::test]
async fn active_relations_for_subject_by_keys_short_circuits_empty_keys() -> Result<(), TestError> {
    let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/keepsake")?;
    let repo = KeepsakeRepository::new(pool);
    let subject = SubjectRef::new("account", "acct_123")?;

    let active = repo
        .active_relations_for_subject_by_keys(&subject, &[])
        .await?;

    assert!(active.is_empty());
    Ok(())
}

#[test]
fn membership_cursor_serializes_for_api_boundaries() -> RepositoryResult<()> {
    let cursor = MembershipCursor {
        subject_kind: "account".to_owned(),
        subject_id: "acct_123".to_owned(),
        keepsake_id: Uuid::nil(),
    };

    let encoded = serde_json::to_string(&cursor)?;
    let decoded = serde_json::from_str::<MembershipCursor>(&encoded)?;

    assert_eq!(decoded, cursor);
    Ok(())
}

#[test]
fn timed_expiry_candidate_serializes_with_stable_field_names() -> Result<(), TestError> {
    let candidate = TimedExpiryCandidate {
        keepsake_id: Uuid::nil(),
        relation_id: Uuid::nil(),
        subject_kind: "account".to_owned(),
        subject_id: "acct_123".to_owned(),
        due_at: ts("2026-01-02T00:00:00Z")?,
    };

    let encoded = serde_json::to_value(&candidate)?;

    assert_eq!(
        encoded,
        serde_json::json!({
            "keepsake_id": "00000000-0000-0000-0000-000000000000",
            "relation_id": "00000000-0000-0000-0000-000000000000",
            "subject_kind": "account",
            "subject_id": "acct_123",
            "due_at": "2026-01-02T00:00:00Z"
        })
    );
    assert_eq!(
        serde_json::from_value::<TimedExpiryCandidate>(encoded)?,
        candidate
    );
    Ok(())
}

#[test]
fn fulfilled_expiry_candidate_serializes_with_stable_field_names() -> Result<(), TestError> {
    let candidate = FulfilledExpiryCandidate {
        keepsake_id: Uuid::nil(),
        relation_id: Uuid::nil(),
        subject_kind: "account".to_owned(),
        subject_id: "acct_123".to_owned(),
        expiry_policy: keepsake::ExpiryPolicy::WhenFulfilled {
            policy: keepsake::FulfillmentPolicy::CounterAtLeast {
                key: "steps".to_owned(),
                threshold: 3,
            },
        },
    };

    let encoded = serde_json::to_value(&candidate)?;

    assert_eq!(
        encoded,
        serde_json::json!({
            "keepsake_id": "00000000-0000-0000-0000-000000000000",
            "relation_id": "00000000-0000-0000-0000-000000000000",
            "subject_kind": "account",
            "subject_id": "acct_123",
            "expiry_policy": {
                "type": "when_fulfilled",
                "policy": {
                    "type": "counter_at_least",
                    "key": "steps",
                    "threshold": 3
                }
            }
        })
    );
    assert_eq!(
        serde_json::from_value::<FulfilledExpiryCandidate>(encoded)?,
        candidate
    );
    Ok(())
}

#[test]
fn parse_state_rejects_unknown_values() {
    let error = parse_state("archived".to_owned())
        .map(|_| ())
        .map_err(|error| error.to_string());

    assert_eq!(error, Err("unknown lifecycle state archived".to_owned()));
}