1use std::collections::BTreeMap;
4
5#[cfg(feature = "cache")]
6use std::sync::Arc;
7#[cfg(feature = "cache")]
8use std::sync::RwLock;
9#[cfg(feature = "cache")]
10use std::time::{Duration, Instant};
11
12use chrono::{DateTime, Utc};
13use keepsake::{
14 ExpiryPolicy, Keepsake, LifecycleState, RelationDefinition, RelationId, RelationKey,
15 RelationSpec, SubjectRef,
16};
17use serde::{Deserialize, Serialize};
18use sqlx::{FromRow, PgPool, Postgres, Transaction};
19use uuid::Uuid;
20
21#[cfg(feature = "migrations")]
22use sqlx::migrate::Migrator;
23
24#[cfg(feature = "migrations")]
25static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
26
27const MAX_BATCH_LIMIT: i64 = 10_000;
28
29pub type RepositoryResult<T> = std::result::Result<T, RepositoryError>;
31
32#[derive(Debug, thiserror::Error)]
34pub enum RepositoryError {
35 #[error(transparent)]
37 Sqlx(#[from] sqlx::Error),
38
39 #[cfg(feature = "migrations")]
41 #[error(transparent)]
42 Migration(#[from] sqlx::migrate::MigrateError),
43
44 #[error(transparent)]
46 Json(#[from] serde_json::Error),
47
48 #[error(transparent)]
50 Keepsake(#[from] keepsake::KeepsakeError),
51
52 #[error("relation {relation_id} is disabled")]
54 RelationDisabled {
55 relation_id: Uuid,
57 },
58
59 #[error(
61 "relation spec {kind}/{name} expected id {expected_relation_id}, but stored relation uses {stored_relation_id}"
62 )]
63 RelationSpecIdMismatch {
64 kind: String,
66 name: String,
68 expected_relation_id: Uuid,
70 stored_relation_id: Uuid,
72 },
73
74 #[error("limit {limit} is outside the accepted range 1..={max}")]
76 InvalidLimit {
77 limit: i64,
79 max: i64,
81 },
82
83 #[error("unknown lifecycle state {state}")]
85 InvalidLifecycleState {
86 state: String,
88 },
89}
90
91#[derive(Debug, Clone)]
93pub struct KeepsakeRepository<C = NoopRelationCache> {
94 pool: PgPool,
95 relation_cache: C,
96}
97
98impl KeepsakeRepository<NoopRelationCache> {
99 #[must_use]
101 pub const fn new(pool: PgPool) -> Self {
102 Self {
103 pool,
104 relation_cache: NoopRelationCache,
105 }
106 }
107}
108
109impl<C> KeepsakeRepository<C>
110where
111 C: RelationCache,
112{
113 pub const fn at(&self, at: DateTime<Utc>) -> TimedKeepsakeRepository<'_, C> {
118 TimedKeepsakeRepository {
119 repository: self,
120 at,
121 }
122 }
123
124 #[must_use]
126 pub fn with_relation_cache<Next>(self, cache: Next) -> KeepsakeRepository<Next>
127 where
128 Next: RelationCache,
129 {
130 KeepsakeRepository {
131 pool: self.pool,
132 relation_cache: cache,
133 }
134 }
135
136 #[cfg(feature = "cache")]
138 #[must_use]
139 pub fn with_local_relation_cache(
140 self,
141 config: LocalRelationCacheConfig,
142 ) -> KeepsakeRepository<LocalRelationCache> {
143 self.with_relation_cache(LocalRelationCache::new(config))
144 }
145
146 #[cfg(feature = "migrations")]
148 pub async fn migrate(&self) -> RepositoryResult<()> {
149 MIGRATOR.run(&self.pool).await?;
150 Ok(())
151 }
152
153 pub async fn upsert_relation(
158 &self,
159 relation: &RelationDefinition,
160 at: DateTime<Utc>,
161 ) -> RepositoryResult<RelationDefinition> {
162 let expiry_policy = serde_json::to_value(&relation.expiry)?;
163 let row = sqlx::query_as::<_, RelationRow>(
164 r"
165 insert into keepsake_relation_definitions
166 (id, kind, key, enabled, expiry_policy, created_at, updated_at)
167 values ($1, $2, $3, $4, $5, $6, $6)
168 on conflict (kind, key) do update set
169 enabled = excluded.enabled,
170 expiry_policy = excluded.expiry_policy,
171 updated_at = $6
172 returning id, kind, key, enabled, expiry_policy
173 ",
174 )
175 .bind(relation.id)
176 .bind(relation.key.kind())
177 .bind(relation.key.name())
178 .bind(relation.enabled)
179 .bind(expiry_policy)
180 .bind(at)
181 .fetch_one(&self.pool)
182 .await?;
183 let relation = row.try_into_relation()?;
184 self.relation_cache.store(&relation).await;
185 Ok(relation)
186 }
187
188 pub async fn upsert_relation_spec<Spec>(
190 &self,
191 at: DateTime<Utc>,
192 ) -> RepositoryResult<RelationDefinition>
193 where
194 Spec: RelationSpec,
195 {
196 let relation = RelationDefinition::from_spec::<Spec>(at)?;
197 let expiry_policy = serde_json::to_value(&relation.expiry)?;
198 let mut tx = self.pool.begin().await?;
199 let row = sqlx::query_as::<_, RelationRow>(
200 r"
201 insert into keepsake_relation_definitions
202 (id, kind, key, enabled, expiry_policy, created_at, updated_at)
203 values ($1, $2, $3, $4, $5, $6, $6)
204 on conflict (kind, key) do update set
205 enabled = excluded.enabled,
206 expiry_policy = excluded.expiry_policy,
207 updated_at = $6
208 where keepsake_relation_definitions.id = excluded.id
209 returning id, kind, key, enabled, expiry_policy
210 ",
211 )
212 .bind(relation.id)
213 .bind(relation.key.kind())
214 .bind(relation.key.name())
215 .bind(relation.enabled)
216 .bind(expiry_policy)
217 .bind(at)
218 .fetch_optional(&mut *tx)
219 .await?;
220
221 let Some(row) = row else {
222 let stored_relation_id = sqlx::query_scalar::<_, Uuid>(
223 r"
224 select id
225 from keepsake_relation_definitions
226 where kind = $1 and key = $2
227 ",
228 )
229 .bind(relation.key.kind())
230 .bind(relation.key.name())
231 .fetch_one(&mut *tx)
232 .await?;
233 return Err(RepositoryError::RelationSpecIdMismatch {
234 kind: relation.key.kind().to_owned(),
235 name: relation.key.name().to_owned(),
236 expected_relation_id: relation.id,
237 stored_relation_id,
238 });
239 };
240
241 tx.commit().await?;
242 let relation = row.try_into_relation()?;
243 self.relation_cache.store(&relation).await;
244 Ok(relation)
245 }
246
247 pub async fn relation_by_id(
249 &self,
250 relation_id: RelationId,
251 ) -> RepositoryResult<Option<RelationDefinition>> {
252 if let Some(relation) = self.relation_cache.get_by_id(relation_id).await {
253 return Ok(Some(relation));
254 }
255
256 let relation = self.fetch_relation_by_id(relation_id).await?;
257 if let Some(relation) = &relation {
258 self.relation_cache.store(relation).await;
259 }
260 Ok(relation)
261 }
262
263 pub async fn relation_by_key(
265 &self,
266 key: &RelationKey,
267 ) -> RepositoryResult<Option<RelationDefinition>> {
268 if let Some(relation) = self.relation_cache.get_by_key(key).await {
269 return Ok(Some(relation));
270 }
271
272 let relation = self.fetch_relation_by_key(key).await?;
273 if let Some(relation) = &relation {
274 self.relation_cache.store(relation).await;
275 }
276 Ok(relation)
277 }
278
279 pub async fn set_relation_enabled(
281 &self,
282 relation_id: RelationId,
283 enabled: bool,
284 at: DateTime<Utc>,
285 ) -> RepositoryResult<bool> {
286 let result = sqlx::query(
287 r"
288 update keepsake_relation_definitions
289 set enabled = $2, updated_at = $3
290 where id = $1
291 ",
292 )
293 .bind(relation_id)
294 .bind(enabled)
295 .bind(at)
296 .execute(&self.pool)
297 .await?;
298 let changed = result.rows_affected() == 1;
299 if changed {
300 self.relation_cache.remove_by_id(relation_id).await;
301 }
302 Ok(changed)
303 }
304
305 async fn fetch_relation_by_id(
306 &self,
307 relation_id: RelationId,
308 ) -> RepositoryResult<Option<RelationDefinition>> {
309 let row = sqlx::query_as::<_, RelationRow>(
310 r"
311 select id, kind, key, enabled, expiry_policy
312 from keepsake_relation_definitions
313 where id = $1
314 ",
315 )
316 .bind(relation_id)
317 .fetch_optional(&self.pool)
318 .await?;
319
320 row.map(RelationRow::try_into_relation).transpose()
321 }
322
323 async fn fetch_relation_by_key(
324 &self,
325 key: &RelationKey,
326 ) -> RepositoryResult<Option<RelationDefinition>> {
327 let row = sqlx::query_as::<_, RelationRow>(
328 r"
329 select id, kind, key, enabled, expiry_policy
330 from keepsake_relation_definitions
331 where kind = $1 and key = $2
332 ",
333 )
334 .bind(key.kind())
335 .bind(key.name())
336 .fetch_optional(&self.pool)
337 .await?;
338
339 row.map(RelationRow::try_into_relation).transpose()
340 }
341
342 pub async fn apply(
348 &self,
349 subject: &SubjectRef,
350 relation_id: RelationId,
351 at: DateTime<Utc>,
352 metadata: &BTreeMap<String, String>,
353 ) -> RepositoryResult<AppliedKeepsake> {
354 let mut tx = self.pool.begin().await?;
355 let relation = relation_for_share_tx(&mut tx, relation_id).await?;
356
357 let keepsake_id = Uuid::now_v7();
358 let metadata = serde_json::to_value(metadata)?;
359
360 let applied = sqlx::query_as::<_, AppliedKeepsakeWriteRow>(
361 r"
362 insert into keepsakes
363 (id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at, expires_at, metadata, created_at, updated_at)
364 select
365 $1,
366 $2,
367 $3,
368 r.id,
369 'applied',
370 r.expiry_policy,
371 $4,
372 case
373 when r.expiry_policy->>'type' = 'at'
374 then (r.expiry_policy->>'timestamp')::timestamptz
375 else null
376 end,
377 $5,
378 $4,
379 $4
380 from keepsake_relation_definitions r
381 where r.id = $6
382 on conflict (subject_kind, subject_id, relation_id) where state = 'applied'
383 do update set updated_at = keepsakes.updated_at
384 returning id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
385 expires_at, fulfilled_at, revoked_at, metadata, (xmax <> 0) as duplicate_prevented
386 ",
387 )
388 .bind(keepsake_id)
389 .bind(&subject.kind)
390 .bind(&subject.id)
391 .bind(at)
392 .bind(metadata)
393 .bind(relation_id)
394 .fetch_one(&mut *tx)
395 .await?;
396
397 if !relation.enabled && !applied.duplicate_prevented {
398 return Err(RepositoryError::RelationDisabled { relation_id });
399 }
400
401 tx.commit().await?;
402 let (keepsake, duplicate_prevented) = applied.try_into_parts()?;
403 Ok(AppliedKeepsake {
404 keepsake,
405 duplicate_prevented,
406 })
407 }
408
409 pub async fn apply_spec<Spec>(
411 &self,
412 subject: &SubjectRef,
413 at: DateTime<Utc>,
414 metadata: &BTreeMap<String, String>,
415 ) -> RepositoryResult<AppliedKeepsake>
416 where
417 Spec: RelationSpec,
418 {
419 self.apply(subject, Spec::ID, at, metadata).await
420 }
421
422 pub async fn apply_without_metadata(
424 &self,
425 subject: &SubjectRef,
426 relation_id: RelationId,
427 at: DateTime<Utc>,
428 ) -> RepositoryResult<AppliedKeepsake> {
429 self.apply(subject, relation_id, at, &BTreeMap::new()).await
430 }
431
432 pub async fn apply_spec_without_metadata<Spec>(
434 &self,
435 subject: &SubjectRef,
436 at: DateTime<Utc>,
437 ) -> RepositoryResult<AppliedKeepsake>
438 where
439 Spec: RelationSpec,
440 {
441 self.apply_spec::<Spec>(subject, at, &BTreeMap::new()).await
442 }
443
444 pub async fn revoke(&self, keepsake_id: Uuid, at: DateTime<Utc>) -> RepositoryResult<bool> {
446 let result = sqlx::query(
447 r"
448 update keepsakes
449 set state = 'revoked', revoked_at = $2, updated_at = $2
450 where id = $1 and state = 'applied'
451 ",
452 )
453 .bind(keepsake_id)
454 .bind(at)
455 .execute(&self.pool)
456 .await?;
457 Ok(result.rows_affected() == 1)
458 }
459
460 pub async fn active_for_subject(
462 &self,
463 subject: &SubjectRef,
464 ) -> RepositoryResult<Vec<Keepsake>> {
465 let rows = sqlx::query_as::<_, AppliedKeepsakeRow>(
466 r"
467 select id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
468 expires_at, fulfilled_at, revoked_at, metadata
469 from keepsakes
470 where subject_kind = $1 and subject_id = $2 and state = 'applied'
471 order by relation_id, id
472 ",
473 )
474 .bind(&subject.kind)
475 .bind(&subject.id)
476 .fetch_all(&self.pool)
477 .await?;
478
479 rows.into_iter()
480 .map(AppliedKeepsakeRow::try_into_keepsake)
481 .collect()
482 }
483
484 pub async fn active_relations_for_subject(
486 &self,
487 subject: &SubjectRef,
488 ) -> RepositoryResult<Vec<ActiveRelation>> {
489 let rows = sqlx::query_as::<_, ActiveRelationRow>(
490 r"
491 select
492 k.id,
493 k.subject_kind,
494 k.subject_id,
495 k.relation_id,
496 k.state,
497 k.expiry_policy,
498 k.applied_at,
499 k.expires_at,
500 k.fulfilled_at,
501 k.revoked_at,
502 k.metadata,
503 r.id as relation_definition_id,
504 r.kind as relation_kind,
505 r.key as relation_key,
506 r.enabled as relation_enabled,
507 r.expiry_policy as relation_expiry_policy
508 from keepsakes k
509 join keepsake_relation_definitions r on r.id = k.relation_id
510 where k.subject_kind = $1 and k.subject_id = $2 and k.state = 'applied'
511 order by k.relation_id, k.id
512 ",
513 )
514 .bind(&subject.kind)
515 .bind(&subject.id)
516 .fetch_all(&self.pool)
517 .await?;
518
519 let mut active = Vec::with_capacity(rows.len());
520 for row in rows {
521 let active_relation = row.try_into_active_relation()?;
522 self.relation_cache.store(&active_relation.relation).await;
523 active.push(active_relation);
524 }
525 Ok(active)
526 }
527
528 pub async fn active_relations_for_subject_by_keys(
535 &self,
536 subject: &SubjectRef,
537 keys: &[RelationKey],
538 ) -> RepositoryResult<Vec<ActiveRelation>> {
539 if keys.is_empty() {
540 return Ok(Vec::new());
541 }
542
543 let kinds = keys
544 .iter()
545 .map(|key| key.kind().to_owned())
546 .collect::<Vec<String>>();
547 let names = keys
548 .iter()
549 .map(|key| key.name().to_owned())
550 .collect::<Vec<String>>();
551
552 let rows = sqlx::query_as::<_, ActiveRelationRow>(
553 r"
554 with requested_relation_keys(kind, key) as (
555 select distinct kind, key
556 from unnest($3::text[], $4::text[]) as requested(kind, key)
557 )
558 select
559 k.id,
560 k.subject_kind,
561 k.subject_id,
562 k.relation_id,
563 k.state,
564 k.expiry_policy,
565 k.applied_at,
566 k.expires_at,
567 k.fulfilled_at,
568 k.revoked_at,
569 k.metadata,
570 r.id as relation_definition_id,
571 r.kind as relation_kind,
572 r.key as relation_key,
573 r.enabled as relation_enabled,
574 r.expiry_policy as relation_expiry_policy
575 from requested_relation_keys requested
576 join keepsake_relation_definitions r
577 on r.kind = requested.kind and r.key = requested.key
578 join keepsakes k
579 on k.relation_id = r.id
580 and k.subject_kind = $1
581 and k.subject_id = $2
582 and k.state = 'applied'
583 order by k.relation_id, k.id
584 ",
585 )
586 .bind(&subject.kind)
587 .bind(&subject.id)
588 .bind(&kinds)
589 .bind(&names)
590 .fetch_all(&self.pool)
591 .await?;
592
593 let mut active = Vec::with_capacity(rows.len());
594 for row in rows {
595 let active_relation = row.try_into_active_relation()?;
596 self.relation_cache.store(&active_relation.relation).await;
597 active.push(active_relation);
598 }
599 Ok(active)
600 }
601
602 pub async fn active_membership_scan(
604 &self,
605 relation_id: RelationId,
606 limit: i64,
607 ) -> RepositoryResult<Vec<Keepsake>> {
608 self.active_membership_scan_after(relation_id, None, limit)
609 .await
610 }
611
612 pub async fn active_membership_scan_after(
614 &self,
615 relation_id: RelationId,
616 after: Option<&MembershipCursor>,
617 limit: i64,
618 ) -> RepositoryResult<Vec<Keepsake>> {
619 let limit = validate_limit(limit)?;
620 let rows = sqlx::query_as::<_, AppliedKeepsakeRow>(
621 r"
622 select id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
623 expires_at, fulfilled_at, revoked_at, metadata
624 from keepsakes
625 where relation_id = $1
626 and state = 'applied'
627 and (
628 $2::text is null
629 or (subject_kind, subject_id, id) > ($2, $3, $4)
630 )
631 order by subject_kind, subject_id, id
632 limit $5
633 ",
634 )
635 .bind(relation_id)
636 .bind(after.map(|cursor| cursor.subject_kind.as_str()))
637 .bind(after.map(|cursor| cursor.subject_id.as_str()))
638 .bind(after.map(|cursor| cursor.keepsake_id))
639 .bind(limit)
640 .fetch_all(&self.pool)
641 .await?;
642
643 rows.into_iter()
644 .map(AppliedKeepsakeRow::try_into_keepsake)
645 .collect()
646 }
647
648 pub async fn due_timed_expiry(
650 &self,
651 now: DateTime<Utc>,
652 limit: i64,
653 ) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
654 let limit = validate_limit(limit)?;
655 let rows = sqlx::query_as::<_, TimedExpiryCandidate>(
656 r"
657 select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
658 from keepsakes k
659 join keepsake_relation_definitions r on r.id = k.relation_id
660 where k.state = 'applied'
661 and r.enabled
662 and k.expires_at is not null
663 and k.expires_at <= $1
664 order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
665 limit $2
666 ",
667 )
668 .bind(now)
669 .bind(limit)
670 .fetch_all(&self.pool)
671 .await?;
672 Ok(rows)
673 }
674
675 pub async fn expire_due_timed(&self, now: DateTime<Utc>, limit: i64) -> RepositoryResult<u64> {
677 let limit = validate_limit(limit)?;
678 let mut tx = self.pool.begin().await?;
679 let rows = due_timed_expiry_tx(&mut tx, now, limit).await?;
680 let ids = rows
681 .into_iter()
682 .map(|row| row.keepsake_id)
683 .collect::<Vec<Uuid>>();
684 if ids.is_empty() {
685 tx.commit().await?;
686 return Ok(0);
687 }
688
689 let result = sqlx::query(
690 r"
691 update keepsakes
692 set state = 'expired', updated_at = $2
693 where id = any($1)
694 and state = 'applied'
695 and exists (
696 select 1
697 from keepsake_relation_definitions r
698 where r.id = keepsakes.relation_id and r.enabled
699 )
700 ",
701 )
702 .bind(&ids)
703 .bind(now)
704 .execute(&mut *tx)
705 .await?;
706 tx.commit().await?;
707 Ok(result.rows_affected())
708 }
709
710 #[cfg(feature = "fulfillment-counters")]
712 pub async fn upsert_counter_projection(
713 &self,
714 keepsake_id: Uuid,
715 key: &str,
716 value: i64,
717 observed_at: DateTime<Utc>,
718 ) -> RepositoryResult<()> {
719 sqlx::query(
720 r"
721 insert into keepsake_fulfillment_counters
722 (keepsake_id, key, value, observed_at)
723 values ($1, $2, $3, $4)
724 on conflict (keepsake_id, key) do update set
725 value = excluded.value,
726 observed_at = excluded.observed_at
727 ",
728 )
729 .bind(keepsake_id)
730 .bind(key)
731 .bind(value)
732 .bind(observed_at)
733 .execute(&self.pool)
734 .await?;
735 Ok(())
736 }
737}
738
739#[derive(Debug, Clone, Copy)]
745pub struct TimedKeepsakeRepository<'repo, C = NoopRelationCache> {
746 repository: &'repo KeepsakeRepository<C>,
747 at: DateTime<Utc>,
748}
749
750impl<'repo, C> TimedKeepsakeRepository<'repo, C>
751where
752 C: RelationCache,
753{
754 #[must_use]
756 pub const fn repository(&self) -> &'repo KeepsakeRepository<C> {
757 self.repository
758 }
759
760 #[must_use]
762 pub const fn timestamp(&self) -> DateTime<Utc> {
763 self.at
764 }
765
766 pub async fn upsert_relation(
768 &self,
769 relation: &RelationDefinition,
770 ) -> RepositoryResult<RelationDefinition> {
771 self.repository.upsert_relation(relation, self.at).await
772 }
773
774 pub async fn upsert_relation_spec<Spec>(&self) -> RepositoryResult<RelationDefinition>
776 where
777 Spec: RelationSpec,
778 {
779 self.repository.upsert_relation_spec::<Spec>(self.at).await
780 }
781
782 pub async fn set_relation_enabled(
784 &self,
785 relation_id: RelationId,
786 enabled: bool,
787 ) -> RepositoryResult<bool> {
788 self.repository
789 .set_relation_enabled(relation_id, enabled, self.at)
790 .await
791 }
792
793 pub async fn apply(
795 &self,
796 subject: &SubjectRef,
797 relation_id: RelationId,
798 metadata: &BTreeMap<String, String>,
799 ) -> RepositoryResult<AppliedKeepsake> {
800 self.repository
801 .apply(subject, relation_id, self.at, metadata)
802 .await
803 }
804
805 pub async fn apply_spec<Spec>(
807 &self,
808 subject: &SubjectRef,
809 metadata: &BTreeMap<String, String>,
810 ) -> RepositoryResult<AppliedKeepsake>
811 where
812 Spec: RelationSpec,
813 {
814 self.repository
815 .apply_spec::<Spec>(subject, self.at, metadata)
816 .await
817 }
818
819 pub async fn apply_without_metadata(
821 &self,
822 subject: &SubjectRef,
823 relation_id: RelationId,
824 ) -> RepositoryResult<AppliedKeepsake> {
825 self.repository
826 .apply_without_metadata(subject, relation_id, self.at)
827 .await
828 }
829
830 pub async fn apply_spec_without_metadata<Spec>(
832 &self,
833 subject: &SubjectRef,
834 ) -> RepositoryResult<AppliedKeepsake>
835 where
836 Spec: RelationSpec,
837 {
838 self.repository
839 .apply_spec_without_metadata::<Spec>(subject, self.at)
840 .await
841 }
842
843 pub async fn revoke(&self, keepsake_id: Uuid) -> RepositoryResult<bool> {
845 self.repository.revoke(keepsake_id, self.at).await
846 }
847
848 pub async fn due_timed_expiry(
850 &self,
851 limit: i64,
852 ) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
853 self.repository.due_timed_expiry(self.at, limit).await
854 }
855
856 pub async fn expire_due_timed(&self, limit: i64) -> RepositoryResult<u64> {
858 self.repository.expire_due_timed(self.at, limit).await
859 }
860
861 #[cfg(feature = "fulfillment-counters")]
863 pub async fn upsert_counter_projection(
864 &self,
865 keepsake_id: Uuid,
866 key: &str,
867 value: i64,
868 ) -> RepositoryResult<()> {
869 self.repository
870 .upsert_counter_projection(keepsake_id, key, value, self.at)
871 .await
872 }
873}
874
875async fn due_timed_expiry_tx(
876 tx: &mut Transaction<'_, Postgres>,
877 now: DateTime<Utc>,
878 limit: i64,
879) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
880 let rows = sqlx::query_as::<_, TimedExpiryCandidate>(
881 r"
882 select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
883 from keepsakes k
884 join keepsake_relation_definitions r on r.id = k.relation_id
885 where k.state = 'applied'
886 and r.enabled
887 and k.expires_at is not null
888 and k.expires_at <= $1
889 order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
890 limit $2
891 for update of k skip locked
892 for share of r
893 ",
894 )
895 .bind(now)
896 .bind(limit)
897 .fetch_all(&mut **tx)
898 .await?;
899 Ok(rows)
900}
901
902fn validate_limit(limit: i64) -> RepositoryResult<i64> {
903 if (1..=MAX_BATCH_LIMIT).contains(&limit) {
904 Ok(limit)
905 } else {
906 Err(RepositoryError::InvalidLimit {
907 limit,
908 max: MAX_BATCH_LIMIT,
909 })
910 }
911}
912
913async fn relation_for_share_tx(
914 tx: &mut Transaction<'_, Postgres>,
915 relation_id: RelationId,
916) -> RepositoryResult<RelationDefinition> {
917 let row = sqlx::query_as::<_, RelationRow>(
918 r"
919 select id, kind, key, enabled, expiry_policy
920 from keepsake_relation_definitions
921 where id = $1
922 for share
923 ",
924 )
925 .bind(relation_id)
926 .fetch_one(&mut **tx)
927 .await?;
928 row.try_into_relation()
929}
930
931#[async_trait::async_trait]
933pub trait RelationCache: Send + Sync + std::fmt::Debug {
934 async fn get_by_id(&self, relation_id: RelationId) -> Option<RelationDefinition>;
936
937 async fn get_by_key(&self, key: &RelationKey) -> Option<RelationDefinition>;
939
940 async fn store(&self, relation: &RelationDefinition);
942
943 async fn remove_by_id(&self, relation_id: RelationId);
945}
946
947#[derive(Debug, Clone, Copy, Default)]
949pub struct NoopRelationCache;
950
951#[async_trait::async_trait]
952impl RelationCache for NoopRelationCache {
953 async fn get_by_id(&self, _relation_id: RelationId) -> Option<RelationDefinition> {
954 None
955 }
956
957 async fn get_by_key(&self, _key: &RelationKey) -> Option<RelationDefinition> {
958 None
959 }
960
961 async fn store(&self, _relation: &RelationDefinition) {}
962
963 async fn remove_by_id(&self, _relation_id: RelationId) {}
964}
965
966#[cfg(feature = "cache")]
968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
969pub struct LocalRelationCacheConfig {
970 pub ttl: Duration,
972}
973
974#[cfg(feature = "cache")]
975impl LocalRelationCacheConfig {
976 #[must_use]
978 pub const fn new(ttl: Duration) -> Self {
979 Self { ttl }
980 }
981}
982
983#[cfg(feature = "cache")]
985#[derive(Debug, Clone)]
986pub struct LocalRelationCache {
987 config: LocalRelationCacheConfig,
988 state: Arc<RwLock<LocalRelationCacheState>>,
992}
993
994#[cfg(feature = "cache")]
995impl LocalRelationCache {
996 #[must_use]
998 pub fn new(config: LocalRelationCacheConfig) -> Self {
999 Self {
1000 config,
1001 state: Arc::new(RwLock::new(LocalRelationCacheState::default())),
1002 }
1003 }
1004}
1005
1006#[cfg(feature = "cache")]
1007#[async_trait::async_trait]
1008impl RelationCache for LocalRelationCache {
1009 async fn get_by_id(&self, relation_id: RelationId) -> Option<RelationDefinition> {
1010 self.state
1011 .read()
1012 .ok()
1013 .and_then(|state| state.by_id.get(&relation_id).cloned())
1014 .and_then(CacheEntry::fresh_relation)
1015 }
1016
1017 async fn get_by_key(&self, key: &RelationKey) -> Option<RelationDefinition> {
1018 self.state
1019 .read()
1020 .ok()
1021 .and_then(|state| state.by_key.get(key).cloned())
1022 .and_then(CacheEntry::fresh_relation)
1023 }
1024
1025 async fn store(&self, relation: &RelationDefinition) {
1026 let entry = CacheEntry {
1027 relation: relation.clone(),
1028 expires_at: Instant::now() + self.config.ttl,
1029 };
1030 if let Ok(mut state) = self.state.write() {
1031 state.by_id.insert(relation.id, entry.clone());
1032 state.by_key.insert(relation.key.clone(), entry);
1033 }
1034 }
1035
1036 async fn remove_by_id(&self, relation_id: RelationId) {
1037 if let Ok(mut state) = self.state.write()
1038 && let Some(entry) = state.by_id.remove(&relation_id)
1039 {
1040 state.by_key.remove(&entry.relation.key);
1041 }
1042 }
1043}
1044
1045#[cfg(feature = "cache")]
1046#[derive(Debug, Default)]
1047struct LocalRelationCacheState {
1048 by_id: BTreeMap<RelationId, CacheEntry>,
1049 by_key: BTreeMap<RelationKey, CacheEntry>,
1050}
1051
1052#[cfg(feature = "cache")]
1053#[derive(Debug, Clone)]
1054struct CacheEntry {
1055 relation: RelationDefinition,
1056 expires_at: Instant,
1057}
1058
1059#[cfg(feature = "cache")]
1060impl CacheEntry {
1061 fn fresh_relation(self) -> Option<RelationDefinition> {
1062 (Instant::now() <= self.expires_at).then_some(self.relation)
1063 }
1064}
1065
1066#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1068pub struct MembershipCursor {
1069 pub subject_kind: String,
1071 pub subject_id: String,
1073 pub keepsake_id: Uuid,
1075}
1076
1077impl MembershipCursor {
1078 #[must_use]
1080 pub fn after(keepsake: &Keepsake) -> Self {
1081 Self {
1082 subject_kind: keepsake.subject.kind.clone(),
1083 subject_id: keepsake.subject.id.clone(),
1084 keepsake_id: keepsake.id,
1085 }
1086 }
1087}
1088
1089#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1091pub struct AppliedKeepsake {
1092 pub keepsake: Keepsake,
1094 pub duplicate_prevented: bool,
1096}
1097
1098#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1100pub struct ActiveRelation {
1101 pub keepsake: Keepsake,
1103 pub relation: RelationDefinition,
1105}
1106
1107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)]
1109pub struct TimedExpiryCandidate {
1110 pub keepsake_id: Uuid,
1112 pub relation_id: Uuid,
1114 pub subject_kind: String,
1116 pub subject_id: String,
1118 pub due_at: DateTime<Utc>,
1120}
1121
1122#[derive(Debug, FromRow)]
1123struct RelationRow {
1124 id: Uuid,
1125 kind: String,
1126 key: String,
1127 enabled: bool,
1128 expiry_policy: serde_json::Value,
1129}
1130
1131impl RelationRow {
1132 fn try_into_relation(self) -> RepositoryResult<RelationDefinition> {
1133 let expiry = serde_json::from_value::<ExpiryPolicy>(self.expiry_policy)?;
1134 Ok(RelationDefinition::new(
1135 self.id,
1136 RelationKey::new(self.kind, self.key)?,
1137 self.enabled,
1138 expiry,
1139 )?)
1140 }
1141}
1142
1143#[derive(Debug, FromRow)]
1144struct AppliedKeepsakeRow {
1145 id: Uuid,
1146 subject_kind: String,
1147 subject_id: String,
1148 relation_id: Uuid,
1149 state: String,
1150 expiry_policy: serde_json::Value,
1151 applied_at: DateTime<Utc>,
1152 expires_at: Option<DateTime<Utc>>,
1153 fulfilled_at: Option<DateTime<Utc>>,
1154 revoked_at: Option<DateTime<Utc>>,
1155 metadata: serde_json::Value,
1156}
1157
1158impl AppliedKeepsakeRow {
1159 fn try_into_keepsake(self) -> RepositoryResult<Keepsake> {
1160 let expiry = serde_json::from_value::<ExpiryPolicy>(self.expiry_policy)?;
1161 let metadata = serde_json::from_value::<BTreeMap<String, String>>(self.metadata)?;
1162 Ok(Keepsake {
1163 id: self.id,
1164 subject: SubjectRef {
1165 kind: self.subject_kind,
1166 id: self.subject_id,
1167 },
1168 relation_id: self.relation_id,
1169 state: parse_state(self.state)?,
1170 expiry,
1171 applied_at: self.applied_at,
1172 expires_at: self.expires_at,
1173 fulfilled_at: self.fulfilled_at,
1174 revoked_at: self.revoked_at,
1175 metadata,
1176 })
1177 }
1178}
1179
1180#[derive(Debug, FromRow)]
1181struct AppliedKeepsakeWriteRow {
1182 id: Uuid,
1183 subject_kind: String,
1184 subject_id: String,
1185 relation_id: Uuid,
1186 state: String,
1187 expiry_policy: serde_json::Value,
1188 applied_at: DateTime<Utc>,
1189 expires_at: Option<DateTime<Utc>>,
1190 fulfilled_at: Option<DateTime<Utc>>,
1191 revoked_at: Option<DateTime<Utc>>,
1192 metadata: serde_json::Value,
1193 duplicate_prevented: bool,
1194}
1195
1196impl AppliedKeepsakeWriteRow {
1197 fn try_into_parts(self) -> RepositoryResult<(Keepsake, bool)> {
1198 let expiry = serde_json::from_value::<ExpiryPolicy>(self.expiry_policy)?;
1199 let metadata = serde_json::from_value::<BTreeMap<String, String>>(self.metadata)?;
1200 let keepsake = Keepsake {
1201 id: self.id,
1202 subject: SubjectRef {
1203 kind: self.subject_kind,
1204 id: self.subject_id,
1205 },
1206 relation_id: self.relation_id,
1207 state: parse_state(self.state)?,
1208 expiry,
1209 applied_at: self.applied_at,
1210 expires_at: self.expires_at,
1211 fulfilled_at: self.fulfilled_at,
1212 revoked_at: self.revoked_at,
1213 metadata,
1214 };
1215 Ok((keepsake, self.duplicate_prevented))
1216 }
1217}
1218
1219#[derive(Debug, FromRow)]
1220struct ActiveRelationRow {
1221 id: Uuid,
1222 subject_kind: String,
1223 subject_id: String,
1224 relation_id: Uuid,
1225 state: String,
1226 expiry_policy: serde_json::Value,
1227 applied_at: DateTime<Utc>,
1228 expires_at: Option<DateTime<Utc>>,
1229 fulfilled_at: Option<DateTime<Utc>>,
1230 revoked_at: Option<DateTime<Utc>>,
1231 metadata: serde_json::Value,
1232 relation_definition_id: Uuid,
1233 relation_kind: String,
1234 relation_key: String,
1235 relation_enabled: bool,
1236 relation_expiry_policy: serde_json::Value,
1237}
1238
1239impl ActiveRelationRow {
1240 fn try_into_active_relation(self) -> RepositoryResult<ActiveRelation> {
1241 let expiry = serde_json::from_value::<ExpiryPolicy>(self.expiry_policy)?;
1242 let relation_expiry = serde_json::from_value::<ExpiryPolicy>(self.relation_expiry_policy)?;
1243 let metadata = serde_json::from_value::<BTreeMap<String, String>>(self.metadata)?;
1244 Ok(ActiveRelation {
1245 keepsake: Keepsake {
1246 id: self.id,
1247 subject: SubjectRef {
1248 kind: self.subject_kind,
1249 id: self.subject_id,
1250 },
1251 relation_id: self.relation_id,
1252 state: parse_state(self.state)?,
1253 expiry,
1254 applied_at: self.applied_at,
1255 expires_at: self.expires_at,
1256 fulfilled_at: self.fulfilled_at,
1257 revoked_at: self.revoked_at,
1258 metadata,
1259 },
1260 relation: RelationDefinition::new(
1261 self.relation_definition_id,
1262 RelationKey::new(self.relation_kind, self.relation_key)?,
1263 self.relation_enabled,
1264 relation_expiry,
1265 )?,
1266 })
1267 }
1268}
1269
1270fn parse_state(value: String) -> RepositoryResult<LifecycleState> {
1271 match value.as_str() {
1272 "applied" => Ok(LifecycleState::Applied),
1273 "revoked" => Ok(LifecycleState::Revoked),
1274 "expired" => Ok(LifecycleState::Expired),
1275 _ => Err(RepositoryError::InvalidLifecycleState { state: value }),
1276 }
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281 use chrono::DateTime;
1282 use sqlx::postgres::PgPoolOptions;
1283
1284 use super::*;
1285
1286 fn ts(value: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
1287 DateTime::parse_from_rfc3339(value).map(|timestamp| timestamp.with_timezone(&Utc))
1288 }
1289
1290 #[derive(Debug, thiserror::Error)]
1291 enum TestError {
1292 #[error(transparent)]
1293 Chrono(#[from] chrono::ParseError),
1294
1295 #[error(transparent)]
1296 Keepsake(#[from] keepsake::KeepsakeError),
1297
1298 #[error(transparent)]
1299 Repository(#[from] RepositoryError),
1300
1301 #[error(transparent)]
1302 SerdeJson(#[from] serde_json::Error),
1303
1304 #[error(transparent)]
1305 Sqlx(#[from] sqlx::Error),
1306 }
1307
1308 #[tokio::test]
1309 async fn timestamp_scoped_repository_reuses_explicit_timestamp() -> Result<(), TestError> {
1310 let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/keepsake")?;
1311 let repo = KeepsakeRepository::new(pool);
1312 let at = ts("2026-01-02T00:00:00Z")?;
1313 let timed_repo = repo.at(at);
1314
1315 assert_eq!(timed_repo.timestamp(), at);
1316 Ok(())
1317 }
1318
1319 #[tokio::test]
1320 async fn active_relations_for_subject_by_keys_short_circuits_empty_keys()
1321 -> Result<(), TestError> {
1322 let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/keepsake")?;
1323 let repo = KeepsakeRepository::new(pool);
1324 let subject = SubjectRef::new("account", "acct_123")?;
1325
1326 let active = repo
1327 .active_relations_for_subject_by_keys(&subject, &[])
1328 .await?;
1329
1330 assert!(active.is_empty());
1331 Ok(())
1332 }
1333
1334 #[test]
1335 fn membership_cursor_serializes_for_api_boundaries() -> RepositoryResult<()> {
1336 let cursor = MembershipCursor {
1337 subject_kind: "account".to_owned(),
1338 subject_id: "acct_123".to_owned(),
1339 keepsake_id: Uuid::nil(),
1340 };
1341
1342 let encoded = serde_json::to_string(&cursor)?;
1343 let decoded = serde_json::from_str::<MembershipCursor>(&encoded)?;
1344
1345 assert_eq!(decoded, cursor);
1346 Ok(())
1347 }
1348
1349 #[test]
1350 fn timed_expiry_candidate_serializes_with_stable_field_names() -> Result<(), TestError> {
1351 let candidate = TimedExpiryCandidate {
1352 keepsake_id: Uuid::nil(),
1353 relation_id: Uuid::nil(),
1354 subject_kind: "account".to_owned(),
1355 subject_id: "acct_123".to_owned(),
1356 due_at: ts("2026-01-02T00:00:00Z")?,
1357 };
1358
1359 let encoded = serde_json::to_value(&candidate)?;
1360
1361 assert_eq!(
1362 encoded,
1363 serde_json::json!({
1364 "keepsake_id": "00000000-0000-0000-0000-000000000000",
1365 "relation_id": "00000000-0000-0000-0000-000000000000",
1366 "subject_kind": "account",
1367 "subject_id": "acct_123",
1368 "due_at": "2026-01-02T00:00:00Z"
1369 })
1370 );
1371 assert_eq!(
1372 serde_json::from_value::<TimedExpiryCandidate>(encoded)?,
1373 candidate
1374 );
1375 Ok(())
1376 }
1377
1378 #[test]
1379 fn parse_state_rejects_unknown_values() {
1380 let error = parse_state("archived".to_owned())
1381 .map(|_| ())
1382 .map_err(|error| error.to_string());
1383
1384 assert_eq!(error, Err("unknown lifecycle state archived".to_owned()));
1385 }
1386}