1use std::collections::BTreeMap;
4
5use chrono::{DateTime, Utc};
6use keepsake::{Keepsake, RelationDefinition, RelationId, RelationKey, RelationSpec, SubjectRef};
7use sqlx::{PgPool, Postgres, Transaction};
8use uuid::Uuid;
9
10#[cfg(feature = "migrations")]
11use sqlx::migrate::Migrator;
12
13mod cache;
14mod rows;
15mod timed;
16mod types;
17
18#[cfg(feature = "cache")]
19pub use cache::{LocalRelationCache, LocalRelationCacheConfig};
20pub use cache::{NoopRelationCache, RelationCache};
21pub use timed::TimedKeepsakeRepository;
22pub use types::{ActiveRelation, AppliedKeepsake, MembershipCursor, TimedExpiryCandidate};
23
24use rows::{ActiveRelationRow, AppliedKeepsakeRow, AppliedKeepsakeWriteRow, RelationRow};
25
26#[cfg(feature = "migrations")]
27static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
28
29const MAX_BATCH_LIMIT: i64 = 10_000;
30
31pub type RepositoryResult<T> = std::result::Result<T, RepositoryError>;
33
34#[derive(Debug, thiserror::Error)]
36pub enum RepositoryError {
37 #[error(transparent)]
39 Sqlx(#[from] sqlx::Error),
40
41 #[cfg(feature = "migrations")]
43 #[error(transparent)]
44 Migration(#[from] sqlx::migrate::MigrateError),
45
46 #[error(transparent)]
48 Json(#[from] serde_json::Error),
49
50 #[error(transparent)]
52 Keepsake(#[from] keepsake::KeepsakeError),
53
54 #[error("relation {relation_id} is disabled")]
56 RelationDisabled {
57 relation_id: Uuid,
59 },
60
61 #[error(
63 "relation spec {kind}/{name} expected id {expected_relation_id}, but stored relation uses {stored_relation_id}"
64 )]
65 RelationSpecIdMismatch {
66 kind: String,
68 name: String,
70 expected_relation_id: Uuid,
72 stored_relation_id: Uuid,
74 },
75
76 #[error("limit {limit} is outside the accepted range 1..={max}")]
78 InvalidLimit {
79 limit: i64,
81 max: i64,
83 },
84
85 #[error("unknown lifecycle state {state}")]
87 InvalidLifecycleState {
88 state: String,
90 },
91}
92
93#[derive(Debug, Clone)]
95pub struct KeepsakeRepository<C = NoopRelationCache> {
96 pool: PgPool,
97 relation_cache: C,
98}
99
100impl KeepsakeRepository<NoopRelationCache> {
101 #[must_use]
103 pub const fn new(pool: PgPool) -> Self {
104 Self {
105 pool,
106 relation_cache: NoopRelationCache,
107 }
108 }
109}
110
111impl<C> KeepsakeRepository<C>
112where
113 C: RelationCache,
114{
115 pub const fn at(&self, at: DateTime<Utc>) -> TimedKeepsakeRepository<'_, C> {
120 TimedKeepsakeRepository {
121 repository: self,
122 at,
123 }
124 }
125
126 #[must_use]
128 pub fn with_relation_cache<Next>(self, cache: Next) -> KeepsakeRepository<Next>
129 where
130 Next: RelationCache,
131 {
132 KeepsakeRepository {
133 pool: self.pool,
134 relation_cache: cache,
135 }
136 }
137
138 #[cfg(feature = "cache")]
140 #[must_use]
141 pub fn with_local_relation_cache(
142 self,
143 config: LocalRelationCacheConfig,
144 ) -> KeepsakeRepository<LocalRelationCache> {
145 self.with_relation_cache(LocalRelationCache::new(config))
146 }
147
148 #[cfg(feature = "migrations")]
150 pub async fn migrate(&self) -> RepositoryResult<()> {
151 MIGRATOR.run(&self.pool).await?;
152 Ok(())
153 }
154
155 pub async fn upsert_relation(
160 &self,
161 relation: &RelationDefinition,
162 at: DateTime<Utc>,
163 ) -> RepositoryResult<RelationDefinition> {
164 let expiry_policy = serde_json::to_value(&relation.expiry)?;
165 let row = sqlx::query_as::<_, RelationRow>(
166 r"
167 insert into keepsake_relation_definitions
168 (id, kind, key, enabled, expiry_policy, created_at, updated_at)
169 values ($1, $2, $3, $4, $5, $6, $6)
170 on conflict (kind, key) do update set
171 enabled = excluded.enabled,
172 expiry_policy = excluded.expiry_policy,
173 updated_at = $6
174 returning id, kind, key, enabled, expiry_policy
175 ",
176 )
177 .bind(relation.id)
178 .bind(relation.key.kind())
179 .bind(relation.key.name())
180 .bind(relation.enabled)
181 .bind(expiry_policy)
182 .bind(at)
183 .fetch_one(&self.pool)
184 .await?;
185 let relation = row.try_into_relation()?;
186 self.relation_cache.store(&relation).await;
187 Ok(relation)
188 }
189
190 pub async fn upsert_relation_spec<Spec>(
192 &self,
193 at: DateTime<Utc>,
194 ) -> RepositoryResult<RelationDefinition>
195 where
196 Spec: RelationSpec,
197 {
198 let relation = RelationDefinition::from_spec::<Spec>(at)?;
199 let expiry_policy = serde_json::to_value(&relation.expiry)?;
200 let mut tx = self.pool.begin().await?;
201 let row = sqlx::query_as::<_, RelationRow>(
202 r"
203 insert into keepsake_relation_definitions
204 (id, kind, key, enabled, expiry_policy, created_at, updated_at)
205 values ($1, $2, $3, $4, $5, $6, $6)
206 on conflict (kind, key) do update set
207 enabled = excluded.enabled,
208 expiry_policy = excluded.expiry_policy,
209 updated_at = $6
210 where keepsake_relation_definitions.id = excluded.id
211 returning id, kind, key, enabled, expiry_policy
212 ",
213 )
214 .bind(relation.id)
215 .bind(relation.key.kind())
216 .bind(relation.key.name())
217 .bind(relation.enabled)
218 .bind(expiry_policy)
219 .bind(at)
220 .fetch_optional(&mut *tx)
221 .await?;
222
223 let Some(row) = row else {
224 let stored_relation_id = sqlx::query_scalar::<_, Uuid>(
225 r"
226 select id
227 from keepsake_relation_definitions
228 where kind = $1 and key = $2
229 ",
230 )
231 .bind(relation.key.kind())
232 .bind(relation.key.name())
233 .fetch_one(&mut *tx)
234 .await?;
235 return Err(RepositoryError::RelationSpecIdMismatch {
236 kind: relation.key.kind().to_owned(),
237 name: relation.key.name().to_owned(),
238 expected_relation_id: relation.id,
239 stored_relation_id,
240 });
241 };
242
243 tx.commit().await?;
244 let relation = row.try_into_relation()?;
245 self.relation_cache.store(&relation).await;
246 Ok(relation)
247 }
248
249 pub async fn relation_by_id(
251 &self,
252 relation_id: RelationId,
253 ) -> RepositoryResult<Option<RelationDefinition>> {
254 if let Some(relation) = self.relation_cache.get_by_id(relation_id).await {
255 return Ok(Some(relation));
256 }
257
258 let relation = self.fetch_relation_by_id(relation_id).await?;
259 if let Some(relation) = &relation {
260 self.relation_cache.store(relation).await;
261 }
262 Ok(relation)
263 }
264
265 pub async fn relation_by_key(
267 &self,
268 key: &RelationKey,
269 ) -> RepositoryResult<Option<RelationDefinition>> {
270 if let Some(relation) = self.relation_cache.get_by_key(key).await {
271 return Ok(Some(relation));
272 }
273
274 let relation = self.fetch_relation_by_key(key).await?;
275 if let Some(relation) = &relation {
276 self.relation_cache.store(relation).await;
277 }
278 Ok(relation)
279 }
280
281 pub async fn set_relation_enabled(
283 &self,
284 relation_id: RelationId,
285 enabled: bool,
286 at: DateTime<Utc>,
287 ) -> RepositoryResult<bool> {
288 let result = sqlx::query(
289 r"
290 update keepsake_relation_definitions
291 set enabled = $2, updated_at = $3
292 where id = $1
293 ",
294 )
295 .bind(relation_id)
296 .bind(enabled)
297 .bind(at)
298 .execute(&self.pool)
299 .await?;
300 let changed = result.rows_affected() == 1;
301 if changed {
302 self.relation_cache.remove_by_id(relation_id).await;
303 }
304 Ok(changed)
305 }
306
307 async fn fetch_relation_by_id(
308 &self,
309 relation_id: RelationId,
310 ) -> RepositoryResult<Option<RelationDefinition>> {
311 let row = sqlx::query_as::<_, RelationRow>(
312 r"
313 select id, kind, key, enabled, expiry_policy
314 from keepsake_relation_definitions
315 where id = $1
316 ",
317 )
318 .bind(relation_id)
319 .fetch_optional(&self.pool)
320 .await?;
321
322 row.map(RelationRow::try_into_relation).transpose()
323 }
324
325 async fn fetch_relation_by_key(
326 &self,
327 key: &RelationKey,
328 ) -> RepositoryResult<Option<RelationDefinition>> {
329 let row = sqlx::query_as::<_, RelationRow>(
330 r"
331 select id, kind, key, enabled, expiry_policy
332 from keepsake_relation_definitions
333 where kind = $1 and key = $2
334 ",
335 )
336 .bind(key.kind())
337 .bind(key.name())
338 .fetch_optional(&self.pool)
339 .await?;
340
341 row.map(RelationRow::try_into_relation).transpose()
342 }
343
344 pub async fn apply(
350 &self,
351 subject: &SubjectRef,
352 relation_id: RelationId,
353 at: DateTime<Utc>,
354 metadata: &BTreeMap<String, String>,
355 ) -> RepositoryResult<AppliedKeepsake> {
356 subject.validate()?;
357 let mut tx = self.pool.begin().await?;
358 let relation = relation_for_share_tx(&mut tx, relation_id).await?;
359
360 let keepsake_id = Uuid::now_v7();
361 let metadata = serde_json::to_value(metadata)?;
362
363 let applied = sqlx::query_as::<_, AppliedKeepsakeWriteRow>(
364 r"
365 insert into keepsakes
366 (id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at, expires_at, metadata, created_at, updated_at)
367 select
368 $1,
369 $2,
370 $3,
371 r.id,
372 'applied',
373 r.expiry_policy,
374 $4,
375 case
376 when r.expiry_policy->>'type' = 'at'
377 then (r.expiry_policy->>'timestamp')::timestamptz
378 else null
379 end,
380 $5,
381 $4,
382 $4
383 from keepsake_relation_definitions r
384 where r.id = $6
385 on conflict (subject_kind, subject_id, relation_id) where state = 'applied'
386 do update set updated_at = keepsakes.updated_at
387 returning id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
388 expires_at, fulfilled_at, revoked_at, metadata, (xmax <> 0) as duplicate_prevented
389 ",
390 )
391 .bind(keepsake_id)
392 .bind(&subject.kind)
393 .bind(&subject.id)
394 .bind(at)
395 .bind(metadata)
396 .bind(relation_id)
397 .fetch_one(&mut *tx)
398 .await?;
399
400 if !relation.enabled && !applied.duplicate_prevented {
401 return Err(RepositoryError::RelationDisabled { relation_id });
402 }
403
404 let (keepsake, duplicate_prevented) = applied.try_into_parts()?;
405 tx.commit().await?;
406 Ok(AppliedKeepsake {
407 keepsake,
408 duplicate_prevented,
409 })
410 }
411
412 pub async fn apply_spec<Spec>(
414 &self,
415 subject: &SubjectRef,
416 at: DateTime<Utc>,
417 metadata: &BTreeMap<String, String>,
418 ) -> RepositoryResult<AppliedKeepsake>
419 where
420 Spec: RelationSpec,
421 {
422 self.apply(subject, Spec::ID, at, metadata).await
423 }
424
425 pub async fn apply_without_metadata(
427 &self,
428 subject: &SubjectRef,
429 relation_id: RelationId,
430 at: DateTime<Utc>,
431 ) -> RepositoryResult<AppliedKeepsake> {
432 self.apply(subject, relation_id, at, &BTreeMap::new()).await
433 }
434
435 pub async fn apply_spec_without_metadata<Spec>(
437 &self,
438 subject: &SubjectRef,
439 at: DateTime<Utc>,
440 ) -> RepositoryResult<AppliedKeepsake>
441 where
442 Spec: RelationSpec,
443 {
444 self.apply_spec::<Spec>(subject, at, &BTreeMap::new()).await
445 }
446
447 pub async fn revoke(&self, keepsake_id: Uuid, at: DateTime<Utc>) -> RepositoryResult<bool> {
449 let result = sqlx::query(
450 r"
451 update keepsakes
452 set state = 'revoked', revoked_at = $2, updated_at = $2
453 where id = $1 and state = 'applied'
454 ",
455 )
456 .bind(keepsake_id)
457 .bind(at)
458 .execute(&self.pool)
459 .await?;
460 Ok(result.rows_affected() == 1)
461 }
462
463 pub async fn active_for_subject(
465 &self,
466 subject: &SubjectRef,
467 ) -> RepositoryResult<Vec<Keepsake>> {
468 let rows = sqlx::query_as::<_, AppliedKeepsakeRow>(
469 r"
470 select id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
471 expires_at, fulfilled_at, revoked_at, metadata
472 from keepsakes
473 where subject_kind = $1 and subject_id = $2 and state = 'applied'
474 order by relation_id, id
475 ",
476 )
477 .bind(&subject.kind)
478 .bind(&subject.id)
479 .fetch_all(&self.pool)
480 .await?;
481
482 rows.into_iter()
483 .map(AppliedKeepsakeRow::try_into_keepsake)
484 .collect()
485 }
486
487 pub async fn active_relations_for_subject(
489 &self,
490 subject: &SubjectRef,
491 ) -> RepositoryResult<Vec<ActiveRelation>> {
492 let rows = sqlx::query_as::<_, ActiveRelationRow>(
493 r"
494 select
495 k.id,
496 k.subject_kind,
497 k.subject_id,
498 k.relation_id,
499 k.state,
500 k.expiry_policy,
501 k.applied_at,
502 k.expires_at,
503 k.fulfilled_at,
504 k.revoked_at,
505 k.metadata,
506 r.id as relation_definition_id,
507 r.kind as relation_kind,
508 r.key as relation_key,
509 r.enabled as relation_enabled,
510 r.expiry_policy as relation_expiry_policy
511 from keepsakes k
512 join keepsake_relation_definitions r on r.id = k.relation_id
513 where k.subject_kind = $1 and k.subject_id = $2 and k.state = 'applied'
514 order by k.relation_id, k.id
515 ",
516 )
517 .bind(&subject.kind)
518 .bind(&subject.id)
519 .fetch_all(&self.pool)
520 .await?;
521
522 let mut active = Vec::with_capacity(rows.len());
523 for row in rows {
524 let active_relation = row.try_into_active_relation()?;
525 self.relation_cache.store(&active_relation.relation).await;
526 active.push(active_relation);
527 }
528 Ok(active)
529 }
530
531 pub async fn active_relations_for_subject_by_keys(
538 &self,
539 subject: &SubjectRef,
540 keys: &[RelationKey],
541 ) -> RepositoryResult<Vec<ActiveRelation>> {
542 if keys.is_empty() {
543 return Ok(Vec::new());
544 }
545
546 let kinds = keys
547 .iter()
548 .map(|key| key.kind().to_owned())
549 .collect::<Vec<String>>();
550 let names = keys
551 .iter()
552 .map(|key| key.name().to_owned())
553 .collect::<Vec<String>>();
554
555 let rows = sqlx::query_as::<_, ActiveRelationRow>(
556 r"
557 with requested_relation_keys(kind, key) as (
558 select distinct kind, key
559 from unnest($3::text[], $4::text[]) as requested(kind, key)
560 )
561 select
562 k.id,
563 k.subject_kind,
564 k.subject_id,
565 k.relation_id,
566 k.state,
567 k.expiry_policy,
568 k.applied_at,
569 k.expires_at,
570 k.fulfilled_at,
571 k.revoked_at,
572 k.metadata,
573 r.id as relation_definition_id,
574 r.kind as relation_kind,
575 r.key as relation_key,
576 r.enabled as relation_enabled,
577 r.expiry_policy as relation_expiry_policy
578 from requested_relation_keys requested
579 join keepsake_relation_definitions r
580 on r.kind = requested.kind and r.key = requested.key
581 join keepsakes k
582 on k.relation_id = r.id
583 and k.subject_kind = $1
584 and k.subject_id = $2
585 and k.state = 'applied'
586 order by k.relation_id, k.id
587 ",
588 )
589 .bind(&subject.kind)
590 .bind(&subject.id)
591 .bind(&kinds)
592 .bind(&names)
593 .fetch_all(&self.pool)
594 .await?;
595
596 let mut active = Vec::with_capacity(rows.len());
597 for row in rows {
598 let active_relation = row.try_into_active_relation()?;
599 self.relation_cache.store(&active_relation.relation).await;
600 active.push(active_relation);
601 }
602 Ok(active)
603 }
604
605 pub async fn active_membership_scan(
607 &self,
608 relation_id: RelationId,
609 limit: i64,
610 ) -> RepositoryResult<Vec<Keepsake>> {
611 self.active_membership_scan_after(relation_id, None, limit)
612 .await
613 }
614
615 pub async fn active_membership_scan_after(
617 &self,
618 relation_id: RelationId,
619 after: Option<&MembershipCursor>,
620 limit: i64,
621 ) -> RepositoryResult<Vec<Keepsake>> {
622 let limit = validate_limit(limit)?;
623 let rows = sqlx::query_as::<_, AppliedKeepsakeRow>(
624 r"
625 select id, subject_kind, subject_id, relation_id, state, expiry_policy, applied_at,
626 expires_at, fulfilled_at, revoked_at, metadata
627 from keepsakes
628 where relation_id = $1
629 and state = 'applied'
630 and (
631 $2::text is null
632 or (subject_kind, subject_id, id) > ($2, $3, $4)
633 )
634 order by subject_kind, subject_id, id
635 limit $5
636 ",
637 )
638 .bind(relation_id)
639 .bind(after.map(|cursor| cursor.subject_kind.as_str()))
640 .bind(after.map(|cursor| cursor.subject_id.as_str()))
641 .bind(after.map(|cursor| cursor.keepsake_id))
642 .bind(limit)
643 .fetch_all(&self.pool)
644 .await?;
645
646 rows.into_iter()
647 .map(AppliedKeepsakeRow::try_into_keepsake)
648 .collect()
649 }
650
651 pub async fn due_timed_expiry(
653 &self,
654 now: DateTime<Utc>,
655 limit: i64,
656 ) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
657 let limit = validate_limit(limit)?;
658 let rows = sqlx::query_as::<_, TimedExpiryCandidate>(
659 r"
660 select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
661 from keepsakes k
662 join keepsake_relation_definitions r on r.id = k.relation_id
663 where k.state = 'applied'
664 and r.enabled
665 and k.expires_at is not null
666 and k.expires_at <= $1
667 order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
668 limit $2
669 ",
670 )
671 .bind(now)
672 .bind(limit)
673 .fetch_all(&self.pool)
674 .await?;
675 Ok(rows)
676 }
677
678 pub async fn expire_due_timed(&self, now: DateTime<Utc>, limit: i64) -> RepositoryResult<u64> {
680 let limit = validate_limit(limit)?;
681 let mut tx = self.pool.begin().await?;
682 let rows = due_timed_expiry_tx(&mut tx, now, limit).await?;
683 let ids = rows
684 .into_iter()
685 .map(|row| row.keepsake_id)
686 .collect::<Vec<Uuid>>();
687 if ids.is_empty() {
688 tx.commit().await?;
689 return Ok(0);
690 }
691
692 let result = sqlx::query(
693 r"
694 update keepsakes
695 set state = 'expired', updated_at = $2
696 where id = any($1)
697 and state = 'applied'
698 and exists (
699 select 1
700 from keepsake_relation_definitions r
701 where r.id = keepsakes.relation_id and r.enabled
702 )
703 ",
704 )
705 .bind(&ids)
706 .bind(now)
707 .execute(&mut *tx)
708 .await?;
709 tx.commit().await?;
710 Ok(result.rows_affected())
711 }
712
713 #[cfg(feature = "fulfillment-counters")]
715 pub async fn upsert_counter_projection(
716 &self,
717 keepsake_id: Uuid,
718 key: &str,
719 value: i64,
720 observed_at: DateTime<Utc>,
721 ) -> RepositoryResult<()> {
722 sqlx::query(
723 r"
724 insert into keepsake_fulfillment_counters
725 (keepsake_id, key, value, observed_at)
726 values ($1, $2, $3, $4)
727 on conflict (keepsake_id, key) do update set
728 value = excluded.value,
729 observed_at = excluded.observed_at
730 ",
731 )
732 .bind(keepsake_id)
733 .bind(key)
734 .bind(value)
735 .bind(observed_at)
736 .execute(&self.pool)
737 .await?;
738 Ok(())
739 }
740}
741
742async fn due_timed_expiry_tx(
743 tx: &mut Transaction<'_, Postgres>,
744 now: DateTime<Utc>,
745 limit: i64,
746) -> RepositoryResult<Vec<TimedExpiryCandidate>> {
747 let rows = sqlx::query_as::<_, TimedExpiryCandidate>(
748 r"
749 select k.id as keepsake_id, k.relation_id, k.subject_kind, k.subject_id, k.expires_at as due_at
750 from keepsakes k
751 join keepsake_relation_definitions r on r.id = k.relation_id
752 where k.state = 'applied'
753 and r.enabled
754 and k.expires_at is not null
755 and k.expires_at <= $1
756 order by k.expires_at, k.relation_id, k.subject_kind, k.subject_id, k.id
757 limit $2
758 for update of k skip locked
759 for share of r
760 ",
761 )
762 .bind(now)
763 .bind(limit)
764 .fetch_all(&mut **tx)
765 .await?;
766 Ok(rows)
767}
768
769fn validate_limit(limit: i64) -> RepositoryResult<i64> {
770 if (1..=MAX_BATCH_LIMIT).contains(&limit) {
771 Ok(limit)
772 } else {
773 Err(RepositoryError::InvalidLimit {
774 limit,
775 max: MAX_BATCH_LIMIT,
776 })
777 }
778}
779
780async fn relation_for_share_tx(
781 tx: &mut Transaction<'_, Postgres>,
782 relation_id: RelationId,
783) -> RepositoryResult<RelationDefinition> {
784 let row = sqlx::query_as::<_, RelationRow>(
785 r"
786 select id, kind, key, enabled, expiry_policy
787 from keepsake_relation_definitions
788 where id = $1
789 for share
790 ",
791 )
792 .bind(relation_id)
793 .fetch_one(&mut **tx)
794 .await?;
795 row.try_into_relation()
796}
797
798#[cfg(test)]
799mod tests {
800 use chrono::DateTime;
801 use sqlx::postgres::PgPoolOptions;
802
803 use super::rows::parse_state;
804 use super::*;
805
806 fn ts(value: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
807 DateTime::parse_from_rfc3339(value).map(|timestamp| timestamp.with_timezone(&Utc))
808 }
809
810 #[derive(Debug, thiserror::Error)]
811 enum TestError {
812 #[error(transparent)]
813 Chrono(#[from] chrono::ParseError),
814
815 #[error(transparent)]
816 Keepsake(#[from] keepsake::KeepsakeError),
817
818 #[error(transparent)]
819 Repository(#[from] RepositoryError),
820
821 #[error(transparent)]
822 SerdeJson(#[from] serde_json::Error),
823
824 #[error(transparent)]
825 Sqlx(#[from] sqlx::Error),
826 }
827
828 #[tokio::test]
829 async fn timestamp_scoped_repository_reuses_explicit_timestamp() -> Result<(), TestError> {
830 let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/keepsake")?;
831 let repo = KeepsakeRepository::new(pool);
832 let at = ts("2026-01-02T00:00:00Z")?;
833 let timed_repo = repo.at(at);
834
835 assert_eq!(timed_repo.timestamp(), at);
836 Ok(())
837 }
838
839 #[tokio::test]
840 async fn active_relations_for_subject_by_keys_short_circuits_empty_keys()
841 -> Result<(), TestError> {
842 let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/keepsake")?;
843 let repo = KeepsakeRepository::new(pool);
844 let subject = SubjectRef::new("account", "acct_123")?;
845
846 let active = repo
847 .active_relations_for_subject_by_keys(&subject, &[])
848 .await?;
849
850 assert!(active.is_empty());
851 Ok(())
852 }
853
854 #[test]
855 fn membership_cursor_serializes_for_api_boundaries() -> RepositoryResult<()> {
856 let cursor = MembershipCursor {
857 subject_kind: "account".to_owned(),
858 subject_id: "acct_123".to_owned(),
859 keepsake_id: Uuid::nil(),
860 };
861
862 let encoded = serde_json::to_string(&cursor)?;
863 let decoded = serde_json::from_str::<MembershipCursor>(&encoded)?;
864
865 assert_eq!(decoded, cursor);
866 Ok(())
867 }
868
869 #[test]
870 fn timed_expiry_candidate_serializes_with_stable_field_names() -> Result<(), TestError> {
871 let candidate = TimedExpiryCandidate {
872 keepsake_id: Uuid::nil(),
873 relation_id: Uuid::nil(),
874 subject_kind: "account".to_owned(),
875 subject_id: "acct_123".to_owned(),
876 due_at: ts("2026-01-02T00:00:00Z")?,
877 };
878
879 let encoded = serde_json::to_value(&candidate)?;
880
881 assert_eq!(
882 encoded,
883 serde_json::json!({
884 "keepsake_id": "00000000-0000-0000-0000-000000000000",
885 "relation_id": "00000000-0000-0000-0000-000000000000",
886 "subject_kind": "account",
887 "subject_id": "acct_123",
888 "due_at": "2026-01-02T00:00:00Z"
889 })
890 );
891 assert_eq!(
892 serde_json::from_value::<TimedExpiryCandidate>(encoded)?,
893 candidate
894 );
895 Ok(())
896 }
897
898 #[test]
899 fn parse_state_rejects_unknown_values() {
900 let error = parse_state("archived".to_owned())
901 .map(|_| ())
902 .map_err(|error| error.to_string());
903
904 assert_eq!(error, Err("unknown lifecycle state archived".to_owned()));
905 }
906}