keepsake-sqlx 1.0.0

SQLx adapter for keepsake lifecycle storage
Documentation
use super::support::*;
use keepsake::ExpiryPolicy;

#[tokio::test]
async fn sqlite_apply_persists_multiple_audit_context_attributes() -> TestResult<()> {
    use keepsake::{ActorRef, ApplyKeepsake, CommandContext, SubjectRef};

    let (repo, pool) = SqliteHarness::repo().await?;
    let relation = upsert_relation::<SqliteHarness>(&repo, ExpiryPolicy::ManualOnly).await?;
    let context = CommandContext::new(ActorRef::new("test", "worker")?)
        .with_idempotency_key("request-1")
        .with_metadata("request_id", "req_123")
        .with_metadata("source", "support");
    let command = ApplyKeepsake::new(
        SubjectRef::new("account", "sqlite_acct_attrs")?,
        relation.id,
        backend_cases::ts("2026-01-01T00:01:00Z")?,
        context,
    );

    repo.apply(&command).await?;

    let attributes = sqlx::query_as::<_, (String, String)>(
        "select key, value from keepsake_audit_context_attributes order by key",
    )
    .fetch_all(&pool)
    .await?;

    assert_eq!(
        attributes,
        vec![
            ("idempotency_key".to_owned(), "request-1".to_owned()),
            ("request_id".to_owned(), "req_123".to_owned()),
            ("source".to_owned(), "support".to_owned()),
        ]
    );
    Ok(())
}

#[tokio::test]
async fn sqlite_audit_event_read_paginates_in_order() -> TestResult<()> {
    use keepsake::{
        ActorRef, ApplyKeepsake, AuditEventType, CommandContext, RevokeKeepsake, SubjectRef,
    };
    use keepsake_sqlx::AuditCursor;

    let (repo, _pool) = SqliteHarness::repo().await?;
    let relation = upsert_relation::<SqliteHarness>(&repo, ExpiryPolicy::ManualOnly).await?;
    let subject = SubjectRef::new("account", "sqlite_acct_audit")?;
    let context = CommandContext::new(ActorRef::new("test", "worker")?)
        .with_idempotency_key("req-1")
        .with_metadata("source", "support");
    let applied = repo
        .apply(&ApplyKeepsake::new(
            subject,
            relation.id,
            backend_cases::ts("2026-01-01T00:01:00Z")?,
            context,
        ))
        .await?;
    repo.revoke(&RevokeKeepsake::new(
        applied.keepsake.id(),
        backend_cases::ts("2026-01-01T00:02:00Z")?,
        CommandContext::new(ActorRef::new("test", "worker")?),
    ))
    .await?;

    let events = repo
        .audit_events_for_keepsake(applied.keepsake.id(), None, 10)
        .await?;
    assert_eq!(events.len(), 2);
    assert_eq!(events[0].event.event_type, AuditEventType::Apply);
    assert_eq!(events[1].event.event_type, AuditEventType::Revoke);
    assert_eq!(
        events[0].event.context.attributes.get("source").cloned(),
        Some("support".to_owned())
    );
    assert_eq!(
        events[0]
            .event
            .context
            .attributes
            .get("idempotency_key")
            .cloned(),
        Some("req-1".to_owned())
    );
    assert!(events[1].event.context.attributes.is_empty());

    let first = repo
        .audit_events_for_keepsake(applied.keepsake.id(), None, 1)
        .await?;
    assert_eq!(first.len(), 1);
    assert_eq!(first[0].event.event_type, AuditEventType::Apply);
    let next = repo
        .audit_events_for_keepsake(
            applied.keepsake.id(),
            Some(&AuditCursor::after(&first[0])),
            10,
        )
        .await?;
    assert_eq!(next.len(), 1);
    assert_eq!(next[0].event.event_type, AuditEventType::Revoke);

    let by_relation = repo
        .audit_events_for_relation(relation.id, None, 10)
        .await?;
    assert_eq!(by_relation.len(), 2);
    Ok(())
}

#[tokio::test]
async fn sqlite_audit_outbox_exports_claims_acks_and_releases() -> TestResult<()> {
    use keepsake::{ActorRef, ApplyKeepsake, AuditEventType, CommandContext, SubjectRef};
    use keepsake_sqlx::AuditOutboxCursor;

    let (repo, _pool) = SqliteHarness::repo().await?;
    let relation = upsert_relation::<SqliteHarness>(&repo, ExpiryPolicy::ManualOnly).await?;
    let applied = repo
        .apply(&ApplyKeepsake::new(
            SubjectRef::new("account", "sqlite_acct_outbox")?,
            relation.id,
            backend_cases::ts("2026-01-01T00:01:00Z")?,
            CommandContext::new(ActorRef::new("test", "worker")?),
        ))
        .await?;

    let outbox = repo.audit_outbox(None, 10).await?;
    assert_eq!(outbox.len(), 1);
    assert_eq!(outbox[0].audit_event_id, 1);
    assert_eq!(outbox[0].payload.event_type, AuditEventType::Apply);
    assert_eq!(outbox[0].payload.keepsake_id, applied.keepsake.id());
    assert!(
        !repo
            .ack_audit_outbox(outbox[0].id, backend_cases::ts("2026-01-01T00:02:00Z")?)
            .await?
    );
    assert!(!repo.release_audit_outbox(outbox[0].id).await?);

    let after = repo
        .audit_outbox(Some(&AuditOutboxCursor::after(&outbox[0])), 10)
        .await?;
    assert!(after.is_empty());

    let claimed = repo
        .claim_audit_outbox(
            "worker-a",
            backend_cases::ts("2026-01-01T00:02:00Z")?,
            backend_cases::ts("2026-01-01T00:05:00Z")?,
            10,
        )
        .await?;
    assert_eq!(claimed.len(), 1);
    assert_eq!(claimed[0].claimed_by.as_deref(), Some("worker-a"));

    assert!(repo.release_audit_outbox(claimed[0].id).await?);
    let reclaimed = repo
        .claim_audit_outbox(
            "worker-b",
            backend_cases::ts("2026-01-01T00:03:00Z")?,
            backend_cases::ts("2026-01-01T00:06:00Z")?,
            10,
        )
        .await?;
    assert_eq!(reclaimed[0].claimed_by.as_deref(), Some("worker-b"));

    assert!(
        repo.ack_audit_outbox(reclaimed[0].id, backend_cases::ts("2026-01-01T00:04:00Z")?)
            .await?
    );
    assert!(repo.audit_outbox(None, 10).await?.is_empty());
    Ok(())
}