Skip to main content

keepsake_sqlx/repository/
expiry.rs

1use chrono::{DateTime, Utc};
2#[cfg(feature = "fulfillment-counters")]
3use keepsake::{ExpiryPolicy, FulfillmentSnapshot};
4#[cfg(feature = "fulfillment-counters")]
5use std::collections::BTreeMap;
6use uuid::Uuid;
7
8#[cfg(feature = "fulfillment-counters")]
9use super::FulfilledExpiryCandidate;
10use super::{
11    KeepsakeRepository, RelationCache, RepositoryResult, TimedExpiryCandidate, validate_limit,
12};
13
14impl<C> KeepsakeRepository<C>
15where
16    C: RelationCache,
17{
18    /// Lists due timed expiry candidates in stable batch order.
19    pub async fn due_timed_expiry(
20        &self,
21        now: DateTime<Utc>,
22        limit: i64,
23    ) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
24        let limit = validate_limit(limit)?;
25        let rows = sqlx::query_as::<_, TimedExpiryCandidate>(
26            r"
27            select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
28            from keepsakes k
29            join keepsake_relation_definitions r on r.id = k.relation_id
30            where k.state = 'applied'
31              and r.enabled
32              and k.expires_at is not null
33              and k.expires_at <= $1
34            order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
35            limit $2
36            ",
37        )
38        .bind(now)
39        .bind(limit)
40        .fetch_all(&self.pool)
41        .await?;
42        Ok(rows)
43    }
44
45    /// Reads the persisted fulfillment counter snapshot for a keepsake.
46    ///
47    /// Checklist state is not persisted by this adapter yet, so snapshots always
48    /// contain an empty checklist map.
49    #[cfg(feature = "fulfillment-counters")]
50    pub async fn fulfillment_snapshot(
51        &self,
52        keepsake_id: Uuid,
53    ) -> RepositoryResult<FulfillmentSnapshot> {
54        let counters = sqlx::query_as::<_, (String, i64)>(
55            r"
56            select key, value
57            from keepsake_fulfillment_counters
58            where keepsake_id = $1
59            ",
60        )
61        .bind(keepsake_id)
62        .fetch_all(&self.pool)
63        .await?
64        .into_iter()
65        .collect::<BTreeMap<_, _>>();
66
67        Ok(FulfillmentSnapshot {
68            counters,
69            checklist: BTreeMap::new(),
70        })
71    }
72
73    /// Lists fulfillment expiry candidates in stable batch order.
74    #[cfg(feature = "fulfillment-counters")]
75    pub async fn due_fulfilled_expiry(
76        &self,
77        limit: i64,
78    ) -> RepositoryResult<Vec<FulfilledExpiryCandidate>> {
79        let limit = validate_limit(limit)?;
80        let rows = sqlx::query_as::<_, FulfilledExpiryCandidate>(
81            r"
82            select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expiry_policy
83            from keepsakes k
84            where k.state = 'applied'
85              and k.expiry_policy->>'type' = 'when_fulfilled'
86            order by k.relation_id, k.subject_kind, k.subject_id, k.id
87            limit $1
88            ",
89        )
90        .bind(limit)
91        .fetch_all(&self.pool)
92        .await?;
93        Ok(rows)
94    }
95
96    /// Expires a stable batch whose persisted counter snapshots satisfy fulfillment policy.
97    ///
98    /// `ChecklistComplete` policies cannot be expired via this method because
99    /// checklist state is not persisted; callers using `ChecklistComplete` must
100    /// evaluate fulfillment in application code and call revoke directly.
101    #[cfg(feature = "fulfillment-counters")]
102    pub async fn expire_due_fulfilled(
103        &self,
104        now: DateTime<Utc>,
105        limit: i64,
106    ) -> RepositoryResult<u64> {
107        let limit = validate_limit(limit)?;
108        let mut tx = self.pool.begin().await?;
109        let candidates = due_fulfilled_expiry_tx(&mut tx, limit).await?;
110        let candidate_ids = candidates
111            .iter()
112            .map(|candidate| candidate.keepsake_id)
113            .collect::<Vec<_>>();
114
115        if candidate_ids.is_empty() {
116            tx.commit().await?;
117            return Ok(0);
118        }
119
120        let counter_rows = sqlx::query_as::<_, (Uuid, String, i64)>(
121            r"
122            select keepsake_id, key, value
123            from keepsake_fulfillment_counters
124            where keepsake_id = any($1)
125            ",
126        )
127        .bind(&candidate_ids)
128        .fetch_all(&mut *tx)
129        .await?;
130        let mut counters_by_keepsake = BTreeMap::<Uuid, BTreeMap<String, i64>>::new();
131        for (keepsake_id, key, value) in counter_rows {
132            counters_by_keepsake
133                .entry(keepsake_id)
134                .or_default()
135                .insert(key, value);
136        }
137
138        let satisfied_ids = candidates
139            .into_iter()
140            .filter_map(|candidate| {
141                let ExpiryPolicy::WhenFulfilled { policy } = candidate.expiry_policy else {
142                    return None;
143                };
144                let snapshot = FulfillmentSnapshot {
145                    counters: counters_by_keepsake
146                        .remove(&candidate.keepsake_id)
147                        .unwrap_or_default(),
148                    checklist: BTreeMap::new(),
149                };
150                policy
151                    .is_fulfilled(&snapshot)
152                    .then_some(candidate.keepsake_id)
153            })
154            .collect::<Vec<_>>();
155
156        if satisfied_ids.is_empty() {
157            tx.commit().await?;
158            return Ok(0);
159        }
160
161        let result = sqlx::query(
162            r"
163            update keepsakes
164            set state = 'expired', fulfilled_at = $2, updated_at = $2
165            where id = any($1)
166              and state = 'applied'
167              and exists (
168                select 1
169                from keepsake_relation_definitions r
170                where r.id = keepsakes.relation_id and r.enabled
171              )
172            ",
173        )
174        .bind(&satisfied_ids)
175        .bind(now)
176        .execute(&mut *tx)
177        .await?;
178        tx.commit().await?;
179        Ok(result.rows_affected())
180    }
181
182    /// Expires a stable batch of due timed keepsakes.
183    pub async fn expire_due_timed(&self, now: DateTime<Utc>, limit: i64) -> RepositoryResult<u64> {
184        let limit = validate_limit(limit)?;
185        let mut tx = self.pool.begin().await?;
186        let rows = due_timed_expiry_tx(&mut tx, now, limit).await?;
187        let ids = rows
188            .into_iter()
189            .map(|row| row.keepsake_id)
190            .collect::<Vec<Uuid>>();
191        if ids.is_empty() {
192            tx.commit().await?;
193            return Ok(0);
194        }
195
196        let result = sqlx::query(
197            r"
198            update keepsakes
199            set state = 'expired', updated_at = $2
200            where id = any($1)
201              and state = 'applied'
202              and exists (
203                select 1
204                from keepsake_relation_definitions r
205                where r.id = keepsakes.relation_id and r.enabled
206              )
207            ",
208        )
209        .bind(&ids)
210        .bind(now)
211        .execute(&mut *tx)
212        .await?;
213        tx.commit().await?;
214        Ok(result.rows_affected())
215    }
216
217    /// Upserts a simple fulfillment counter projection.
218    #[cfg(feature = "fulfillment-counters")]
219    pub async fn upsert_counter_projection(
220        &self,
221        keepsake_id: Uuid,
222        key: &str,
223        value: i64,
224        observed_at: DateTime<Utc>,
225    ) -> RepositoryResult<()> {
226        sqlx::query(
227            r"
228            insert into keepsake_fulfillment_counters
229                (keepsake_id, key, value, observed_at)
230            values ($1, $2, $3, $4)
231            on conflict (keepsake_id, key) do update set
232                value = excluded.value,
233                observed_at = excluded.observed_at
234            ",
235        )
236        .bind(keepsake_id)
237        .bind(key)
238        .bind(value)
239        .bind(observed_at)
240        .execute(&self.pool)
241        .await?;
242        Ok(())
243    }
244}
245
246async fn due_timed_expiry_tx(
247    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
248    now: DateTime<Utc>,
249    limit: i64,
250) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
251    let rows = sqlx::query_as::<_, TimedExpiryCandidate>(
252        r"
253        select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
254        from keepsakes k
255        join keepsake_relation_definitions r on r.id = k.relation_id
256        where k.state = 'applied'
257          and r.enabled
258          and k.expires_at is not null
259          and k.expires_at <= $1
260        order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
261        limit $2
262        for update of k skip locked
263        for share of r
264        ",
265    )
266    .bind(now)
267    .bind(limit)
268    .fetch_all(&mut **tx)
269    .await?;
270    Ok(rows)
271}
272
273#[cfg(feature = "fulfillment-counters")]
274async fn due_fulfilled_expiry_tx(
275    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
276    limit: i64,
277) -> RepositoryResult<Vec<FulfilledExpiryCandidate>> {
278    let rows = sqlx::query_as::<_, FulfilledExpiryCandidate>(
279        r"
280        select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expiry_policy
281        from keepsakes k
282        where k.state = 'applied'
283          and k.expiry_policy->>'type' = 'when_fulfilled'
284        order by k.relation_id, k.subject_kind, k.subject_id, k.id
285        limit $1
286        for update of k skip locked
287        ",
288    )
289    .bind(limit)
290    .fetch_all(&mut **tx)
291    .await?;
292    Ok(rows)
293}