Skip to main content

keepsake_sqlx/repository/
mutation.rs

1use keepsake::{
2    ApplyKeepsake, Keepsake, KeepsakeId, RelationDefinition, RelationId, RevokeBySubject,
3    RevokeKeepsake, SubjectRef,
4};
5use sqlx::{Postgres, Transaction};
6use uuid::Uuid;
7
8use super::audit::record_audit_event_tx;
9use super::support::{apply_event, revoke_by_subject_event, revoke_event};
10use super::{
11    AppliedKeepsake, AppliedKeepsakeRow, AppliedKeepsakeWriteRow, KeepsakeRepository,
12    RelationCache, RelationRow, RepositoryError, RepositoryResult,
13};
14
15impl<C> KeepsakeRepository<C>
16where
17    C: RelationCache,
18{
19    /// Applies a command idempotently and records its audit event atomically.
20    ///
21    /// If an active keepsake already exists for the subject and relation, the existing
22    /// row is returned with `duplicate_prevented` set to true, even if the relation
23    /// has since been disabled. Disabled relations reject new non-duplicate applies.
24    pub async fn apply(&self, command: &ApplyKeepsake) -> RepositoryResult<AppliedKeepsake> {
25        command.subject.validate()?;
26        command.context.validate()?;
27
28        let mut tx = self.pool.begin().await?;
29        let relation = relation_for_share_tx(&mut tx, command.relation_id).await?;
30        let metadata = serde_json::to_value(&command.metadata)?;
31
32        let applied = sqlx::query_as::<_, AppliedKeepsakeWriteRow>(
33            r"
34            insert into keepsakes
35                (id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at, expires_at, metadata, created_at, updated_at)
36            select
37                $1,
38                $2,
39                $3,
40                r.id,
41                'applied',
42                r.expiry_policy,
43                $4,
44                case
45                    when r.expiry_policy->>'type' = 'at'
46                    then (r.expiry_policy->>'timestamp')::timestamptz
47                    else null
48                end,
49                $5,
50                $4,
51                $4
52            from keepsake_relation_definitions r
53            where r.id = $6
54            on conflict (subject_kind, subject_id, relation_id) where state = 'applied'
55            do update set updated_at = keepsakes.updated_at
56            returning id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
57                expires_at, fulfilled_at, revoked_at, metadata, (xmax <> 0) as duplicate_prevented
58            ",
59        )
60        .bind(command.id)
61        .bind(command.subject.kind())
62        .bind(command.subject.id())
63        .bind(command.at)
64        .bind(metadata)
65        .bind(command.relation_id)
66        .fetch_one(&mut *tx)
67        .await?;
68
69        if !relation.enabled && !applied.duplicate_prevented {
70            return Err(RepositoryError::RelationDisabled {
71                relation_id: command.relation_id,
72            });
73        }
74
75        let (keepsake, duplicate_prevented) = applied.try_into_parts()?;
76        let event = apply_event(command, &keepsake, duplicate_prevented);
77        record_audit_event_tx(&mut tx, &event).await?;
78        tx.commit().await?;
79        Ok(AppliedKeepsake {
80            keepsake,
81            duplicate_prevented,
82        })
83    }
84
85    /// Revokes an active keepsake from a command and records its audit event atomically.
86    pub async fn revoke(&self, command: &RevokeKeepsake) -> RepositoryResult<bool> {
87        command.context.validate()?;
88
89        let mut tx = self.pool.begin().await?;
90        let revoked = revoke_tx(&mut tx, command.keepsake_id, command.at).await?;
91        if let Some(keepsake) = &revoked {
92            let event = revoke_event(command, keepsake);
93            record_audit_event_tx(&mut tx, &event).await?;
94        }
95        tx.commit().await?;
96        Ok(revoked.is_some())
97    }
98
99    /// Revokes the active keepsake for a subject and relation pair.
100    ///
101    /// Returns the revoked keepsake id, or `None` when no active keepsake exists
102    /// for the pair. The active uniqueness invariant guarantees at most one match.
103    pub async fn revoke_by_subject(
104        &self,
105        command: &RevokeBySubject,
106    ) -> RepositoryResult<Option<KeepsakeId>> {
107        command.subject.validate()?;
108        command.context.validate()?;
109
110        let mut tx = self.pool.begin().await?;
111        let revoked =
112            revoke_by_subject_tx(&mut tx, &command.subject, command.relation_id, command.at)
113                .await?;
114        let revoked_id = revoked.as_ref().map(Keepsake::id);
115        if let Some(keepsake) = &revoked {
116            let event = revoke_by_subject_event(command, keepsake);
117            record_audit_event_tx(&mut tx, &event).await?;
118        }
119        tx.commit().await?;
120        Ok(revoked_id)
121    }
122}
123
124async fn revoke_by_subject_tx(
125    tx: &mut Transaction<'_, Postgres>,
126    subject: &SubjectRef,
127    relation_id: RelationId,
128    at: chrono::DateTime<chrono::Utc>,
129) -> RepositoryResult<Option<Keepsake>> {
130    let row = sqlx::query_as::<_, AppliedKeepsakeRow>(
131        r"
132        update keepsakes
133        set state = 'revoked', revoked_at = $4, updated_at = $4
134        where subject_kind = $1 and subject_id = $2 and relation_id = $3 and state = 'applied'
135        returning id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
136            expires_at, fulfilled_at, revoked_at, metadata
137        ",
138    )
139    .bind(subject.kind())
140    .bind(subject.id())
141    .bind(relation_id)
142    .bind(at)
143    .fetch_optional(&mut **tx)
144    .await?;
145
146    row.map(AppliedKeepsakeRow::try_into_keepsake).transpose()
147}
148
149async fn revoke_tx(
150    tx: &mut Transaction<'_, Postgres>,
151    keepsake_id: Uuid,
152    at: chrono::DateTime<chrono::Utc>,
153) -> RepositoryResult<Option<Keepsake>> {
154    let row = sqlx::query_as::<_, AppliedKeepsakeRow>(
155        r"
156        update keepsakes
157        set state = 'revoked', revoked_at = $2, updated_at = $2
158        where id = $1 and state = 'applied'
159        returning id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
160            expires_at, fulfilled_at, revoked_at, metadata
161        ",
162    )
163    .bind(keepsake_id)
164    .bind(at)
165    .fetch_optional(&mut **tx)
166    .await?;
167
168    row.map(AppliedKeepsakeRow::try_into_keepsake).transpose()
169}
170
171async fn relation_for_share_tx(
172    tx: &mut Transaction<'_, Postgres>,
173    relation_id: RelationId,
174) -> RepositoryResult<RelationDefinition> {
175    let row = sqlx::query_as::<_, RelationRow>(
176        r"
177        select id, kind, key, enabled, expiry_policy
178        from keepsake_relation_definitions
179        where id = $1
180        for share
181        ",
182    )
183    .bind(relation_id)
184    .fetch_one(&mut **tx)
185    .await?;
186    row.try_into_relation()
187}