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 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 #[cfg(feature = "fulfillment-counters")]
47 pub async fn fulfillment_snapshot(
48 &self,
49 keepsake_id: Uuid,
50 ) -> RepositoryResult<FulfillmentSnapshot> {
51 let counters = sqlx::query_as::<_, (String, i64)>(
52 r"
53 select key, value
54 from keepsake_fulfillment_counters
55 where keepsake_id = $1
56 ",
57 )
58 .bind(keepsake_id)
59 .fetch_all(&self.pool)
60 .await?
61 .into_iter()
62 .collect::<BTreeMap<_, _>>();
63
64 let checklist = sqlx::query_as::<_, (String, bool)>(
65 r"
66 select item, complete
67 from keepsake_fulfillment_checklist
68 where keepsake_id = $1
69 ",
70 )
71 .bind(keepsake_id)
72 .fetch_all(&self.pool)
73 .await?
74 .into_iter()
75 .collect::<BTreeMap<_, _>>();
76
77 Ok(FulfillmentSnapshot {
78 counters,
79 checklist,
80 })
81 }
82
83 #[cfg(feature = "fulfillment-counters")]
85 pub async fn due_fulfilled_expiry(
86 &self,
87 limit: i64,
88 ) -> RepositoryResult<Vec<FulfilledExpiryCandidate>> {
89 let limit = validate_limit(limit)?;
90 let rows = sqlx::query_as::<_, FulfilledExpiryCandidate>(
91 r"
92 select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expiry_policy
93 from keepsakes k
94 join keepsake_relation_definitions r on r.id = k.relation_id
95 where k.state = 'applied'
96 and r.enabled
97 and k.expiry_policy->>'type' = 'when_fulfilled'
98 order by k.relation_id, k.subject_kind, k.subject_id, k.id
99 limit $1
100 ",
101 )
102 .bind(limit)
103 .fetch_all(&self.pool)
104 .await?;
105 Ok(rows)
106 }
107
108 #[cfg(feature = "fulfillment-counters")]
110 pub async fn expire_due_fulfilled(
111 &self,
112 now: DateTime<Utc>,
113 limit: i64,
114 ) -> RepositoryResult<u64> {
115 let limit = validate_limit(limit)?;
116 let mut tx = self.pool.begin().await?;
117 let target =
118 usize::try_from(limit).map_err(|error| sqlx::Error::Decode(Box::new(error)))?;
119 let mut after = None;
120 let mut satisfied_ids = Vec::new();
121
122 while satisfied_ids.len() < target {
123 let remaining = i64::try_from(target - satisfied_ids.len())
124 .map_err(|error| sqlx::Error::Decode(Box::new(error)))?;
125 let candidates = due_fulfilled_expiry_tx(&mut tx, after.as_ref(), remaining).await?;
126 if candidates.is_empty() {
127 break;
128 }
129 after = candidates.last().map(FulfilledExpiryCursor::from);
130 satisfied_ids.extend(satisfied_fulfillment_ids_tx(&mut tx, candidates).await?);
131 }
132
133 if satisfied_ids.is_empty() {
134 tx.commit().await?;
135 return Ok(0);
136 }
137
138 let result = sqlx::query(
139 r"
140 update keepsakes
141 set state = 'expired', fulfilled_at = $2, updated_at = $2
142 where id = any($1)
143 and state = 'applied'
144 and exists (
145 select 1
146 from keepsake_relation_definitions r
147 where r.id = keepsakes.relation_id and r.enabled
148 )
149 ",
150 )
151 .bind(&satisfied_ids)
152 .bind(now)
153 .execute(&mut *tx)
154 .await?;
155 tx.commit().await?;
156 Ok(result.rows_affected())
157 }
158
159 pub async fn expire_due_timed(&self, now: DateTime<Utc>, limit: i64) -> RepositoryResult<u64> {
161 let limit = validate_limit(limit)?;
162 let mut tx = self.pool.begin().await?;
163 let rows = due_timed_expiry_tx(&mut tx, now, limit).await?;
164 let ids = rows
165 .into_iter()
166 .map(|row| row.keepsake_id)
167 .collect::<Vec<Uuid>>();
168 if ids.is_empty() {
169 tx.commit().await?;
170 return Ok(0);
171 }
172
173 let result = sqlx::query(
174 r"
175 update keepsakes
176 set state = 'expired', updated_at = $2
177 where id = any($1)
178 and state = 'applied'
179 and exists (
180 select 1
181 from keepsake_relation_definitions r
182 where r.id = keepsakes.relation_id and r.enabled
183 )
184 ",
185 )
186 .bind(&ids)
187 .bind(now)
188 .execute(&mut *tx)
189 .await?;
190 tx.commit().await?;
191 Ok(result.rows_affected())
192 }
193
194 #[cfg(feature = "fulfillment-counters")]
196 pub async fn upsert_counter_projection(
197 &self,
198 keepsake_id: Uuid,
199 key: &str,
200 value: i64,
201 observed_at: DateTime<Utc>,
202 ) -> RepositoryResult<()> {
203 sqlx::query(
204 r"
205 insert into keepsake_fulfillment_counters
206 (keepsake_id, key, value, observed_at)
207 values ($1, $2, $3, $4)
208 on conflict (keepsake_id, key) do update set
209 value = excluded.value,
210 observed_at = excluded.observed_at
211 ",
212 )
213 .bind(keepsake_id)
214 .bind(key)
215 .bind(value)
216 .bind(observed_at)
217 .execute(&self.pool)
218 .await?;
219 Ok(())
220 }
221
222 #[cfg(feature = "fulfillment-counters")]
228 pub async fn increment_counter_projection(
229 &self,
230 keepsake_id: Uuid,
231 key: &str,
232 delta: i64,
233 observed_at: DateTime<Utc>,
234 ) -> RepositoryResult<i64> {
235 let (value,) = sqlx::query_as::<_, (i64,)>(
236 r"
237 insert into keepsake_fulfillment_counters
238 (keepsake_id, key, value, observed_at)
239 values ($1, $2, $3, $4)
240 on conflict (keepsake_id, key) do update set
241 value = keepsake_fulfillment_counters.value + excluded.value,
242 observed_at = excluded.observed_at
243 returning value
244 ",
245 )
246 .bind(keepsake_id)
247 .bind(key)
248 .bind(delta)
249 .bind(observed_at)
250 .fetch_one(&self.pool)
251 .await?;
252 Ok(value)
253 }
254
255 #[cfg(feature = "fulfillment-counters")]
257 pub async fn upsert_checklist_projection(
258 &self,
259 keepsake_id: Uuid,
260 item: &str,
261 complete: bool,
262 observed_at: DateTime<Utc>,
263 ) -> RepositoryResult<()> {
264 sqlx::query(
265 r"
266 insert into keepsake_fulfillment_checklist
267 (keepsake_id, item, complete, observed_at)
268 values ($1, $2, $3, $4)
269 on conflict (keepsake_id, item) do update set
270 complete = excluded.complete,
271 observed_at = excluded.observed_at
272 ",
273 )
274 .bind(keepsake_id)
275 .bind(item)
276 .bind(complete)
277 .bind(observed_at)
278 .execute(&self.pool)
279 .await?;
280 Ok(())
281 }
282}
283
284#[cfg(feature = "fulfillment-counters")]
285async fn satisfied_fulfillment_ids_tx(
286 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
287 candidates: Vec<FulfilledExpiryCandidate>,
288) -> RepositoryResult<Vec<Uuid>> {
289 let candidate_ids = candidates
290 .iter()
291 .map(|candidate| candidate.keepsake_id)
292 .collect::<Vec<_>>();
293 if candidate_ids.is_empty() {
294 return Ok(Vec::new());
295 }
296
297 let counter_rows = sqlx::query_as::<_, (Uuid, String, i64)>(
298 r"
299 select keepsake_id, key, value
300 from keepsake_fulfillment_counters
301 where keepsake_id = any($1)
302 ",
303 )
304 .bind(&candidate_ids)
305 .fetch_all(&mut **tx)
306 .await?;
307 let mut counters_by_keepsake = BTreeMap::<Uuid, BTreeMap<String, i64>>::new();
308 for (keepsake_id, key, value) in counter_rows {
309 counters_by_keepsake
310 .entry(keepsake_id)
311 .or_default()
312 .insert(key, value);
313 }
314
315 let checklist_rows = sqlx::query_as::<_, (Uuid, String, bool)>(
316 r"
317 select keepsake_id, item, complete
318 from keepsake_fulfillment_checklist
319 where keepsake_id = any($1)
320 ",
321 )
322 .bind(&candidate_ids)
323 .fetch_all(&mut **tx)
324 .await?;
325 let mut checklist_by_keepsake = BTreeMap::<Uuid, BTreeMap<String, bool>>::new();
326 for (keepsake_id, item, complete) in checklist_rows {
327 checklist_by_keepsake
328 .entry(keepsake_id)
329 .or_default()
330 .insert(item, complete);
331 }
332
333 Ok(candidates
334 .into_iter()
335 .filter_map(|candidate| {
336 let ExpiryPolicy::WhenFulfilled { policy } = candidate.expiry_policy else {
337 return None;
338 };
339 let snapshot = FulfillmentSnapshot {
340 counters: counters_by_keepsake
341 .remove(&candidate.keepsake_id)
342 .unwrap_or_default(),
343 checklist: checklist_by_keepsake
344 .remove(&candidate.keepsake_id)
345 .unwrap_or_default(),
346 };
347 policy
348 .is_fulfilled(&snapshot)
349 .then_some(candidate.keepsake_id)
350 })
351 .collect())
352}
353
354async fn due_timed_expiry_tx(
355 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
356 now: DateTime<Utc>,
357 limit: i64,
358) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
359 let rows = sqlx::query_as::<_, TimedExpiryCandidate>(
360 r"
361 select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
362 from keepsakes k
363 join keepsake_relation_definitions r on r.id = k.relation_id
364 where k.state = 'applied'
365 and r.enabled
366 and k.expires_at is not null
367 and k.expires_at <= $1
368 order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
369 limit $2
370 for update of k skip locked
371 for share of r
372 ",
373 )
374 .bind(now)
375 .bind(limit)
376 .fetch_all(&mut **tx)
377 .await?;
378 Ok(rows)
379}
380
381#[cfg(feature = "fulfillment-counters")]
382async fn due_fulfilled_expiry_tx(
383 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
384 after: Option<&FulfilledExpiryCursor>,
385 limit: i64,
386) -> RepositoryResult<Vec<FulfilledExpiryCandidate>> {
387 let rows = sqlx::query_as::<_, FulfilledExpiryCandidate>(
388 r"
389 select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expiry_policy
390 from keepsakes k
391 join keepsake_relation_definitions r on r.id = k.relation_id
392 where k.state = 'applied'
393 and r.enabled
394 and k.expiry_policy->>'type' = 'when_fulfilled'
395 and (
396 $2::uuid is null
397 or (k.relation_id, k.subject_kind, k.subject_id, k.id) > ($2, $3::text, $4::text, $5::uuid)
398 )
399 order by k.relation_id, k.subject_kind, k.subject_id, k.id
400 limit $1
401 for update of k skip locked
402 for share of r
403 ",
404 )
405 .bind(limit)
406 .bind(after.map(|cursor| cursor.relation_id))
407 .bind(after.map(|cursor| cursor.subject_kind.as_str()))
408 .bind(after.map(|cursor| cursor.subject_id.as_str()))
409 .bind(after.map(|cursor| cursor.keepsake_id))
410 .fetch_all(&mut **tx)
411 .await?;
412 Ok(rows)
413}
414
415#[cfg(feature = "fulfillment-counters")]
416#[derive(Debug, Clone)]
417struct FulfilledExpiryCursor {
418 relation_id: Uuid,
419 subject_kind: String,
420 subject_id: String,
421 keepsake_id: Uuid,
422}
423
424#[cfg(feature = "fulfillment-counters")]
425impl From<&FulfilledExpiryCandidate> for FulfilledExpiryCursor {
426 fn from(candidate: &FulfilledExpiryCandidate) -> Self {
427 Self {
428 relation_id: candidate.relation_id,
429 subject_kind: candidate.subject_kind.clone(),
430 subject_id: candidate.subject_id.clone(),
431 keepsake_id: candidate.keepsake_id,
432 }
433 }
434}