Skip to main content

keepsake_sqlx/
repository.rs

1//! Postgres repository implementation.
2
3use 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
31/// Result alias for SQL repository operations.
32pub type RepositoryResult<T> = std::result::Result<T, RepositoryError>;
33
34/// SQL repository errors.
35#[derive(Debug, thiserror::Error)]
36pub enum RepositoryError {
37    /// `SQLx` returned an error.
38    #[error(transparent)]
39    Sqlx(#[from] sqlx::Error),
40
41    /// Migration failed.
42    #[cfg(feature = "migrations")]
43    #[error(transparent)]
44    Migration(#[from] sqlx::migrate::MigrateError),
45
46    /// JSON policy could not be encoded or decoded.
47    #[error(transparent)]
48    Json(#[from] serde_json::Error),
49
50    /// A Keepsake core model could not be built.
51    #[error(transparent)]
52    Keepsake(#[from] keepsake::KeepsakeError),
53
54    /// A command tried to mutate a disabled relation.
55    #[error("relation {relation_id} is disabled")]
56    RelationDisabled {
57        /// Disabled relation id.
58        relation_id: Uuid,
59    },
60
61    /// A typed relation spec conflicts with an existing natural-key row.
62    #[error(
63        "relation spec {kind}/{name} expected id {expected_relation_id}, but stored relation uses {stored_relation_id}"
64    )]
65    RelationSpecIdMismatch {
66        /// Relation kind.
67        kind: String,
68        /// Relation name.
69        name: String,
70        /// Relation id declared by the typed spec.
71        expected_relation_id: Uuid,
72        /// Existing stored relation id for the same natural key.
73        stored_relation_id: Uuid,
74    },
75
76    /// A batch or scan limit was outside the accepted range.
77    #[error("limit {limit} is outside the accepted range 1..={max}")]
78    InvalidLimit {
79        /// Provided limit.
80        limit: i64,
81        /// Maximum accepted limit.
82        max: i64,
83    },
84
85    /// A row contained an unknown lifecycle state.
86    #[error("unknown lifecycle state {state}")]
87    InvalidLifecycleState {
88        /// Stored state value.
89        state: String,
90    },
91}
92
93/// `SQLx`-backed keepsake repository.
94#[derive(Debug, Clone)]
95pub struct KeepsakeRepository<C = NoopRelationCache> {
96    pool: PgPool,
97    relation_cache: C,
98}
99
100impl KeepsakeRepository<NoopRelationCache> {
101    /// Creates a repository from a Postgres pool.
102    #[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    /// Creates a timestamp-scoped repository view.
116    ///
117    /// Use this at request or job boundaries to keep one explicit clock read while
118    /// avoiding repeated timestamp plumbing through related repository calls.
119    pub const fn at(&self, at: DateTime<Utc>) -> TimedKeepsakeRepository<'_, C> {
120        TimedKeepsakeRepository {
121            repository: self,
122            at,
123        }
124    }
125
126    /// Enables relation definition caching for read helper methods.
127    #[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    /// Enables local in-process relation definition caching for read helper methods.
139    #[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    /// Runs embedded migrations.
149    #[cfg(feature = "migrations")]
150    pub async fn migrate(&self) -> RepositoryResult<()> {
151        MIGRATOR.run(&self.pool).await?;
152        Ok(())
153    }
154
155    /// Inserts or updates a relation definition by its natural relation key.
156    ///
157    /// If a relation already exists for the same kind/name, its stable id is preserved and
158    /// the returned definition contains the existing id.
159    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    /// Inserts or updates a typed relation spec by its natural relation key.
191    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    /// Looks up a relation definition by stable id.
250    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    /// Looks up a relation definition by its natural relation key.
266    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    /// Enables or disables a relation.
282    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    /// Applies a keepsake relation idempotently.
345    ///
346    /// If an active keepsake already exists for the subject and relation, the existing
347    /// row is returned with `duplicate_prevented` set to true, even if the relation
348    /// has since been disabled. Disabled relations reject new non-duplicate applies.
349    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    /// Applies a typed keepsake relation idempotently.
413    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    /// Applies a keepsake relation with empty metadata.
426    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    /// Applies a typed keepsake relation with empty metadata.
436    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    /// Revokes an active keepsake.
448    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    /// Returns active keepsakes for a subject.
464    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    /// Returns active keepsakes for a subject with their relation definitions.
488    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    /// Returns active keepsakes for a subject, filtered by relation keys.
532    ///
533    /// This is the bounded variant of [`Self::active_relations_for_subject`] for
534    /// request paths that know the small set of relation keys they care about.
535    /// Missing keys are ignored, and disabled relation definitions are still
536    /// returned when their keepsake is active.
537    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    /// Scans active memberships for a relation in stable order.
606    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    /// Scans active memberships after a keyset cursor in stable order.
616    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    /// Lists due timed expiry candidates in stable batch order.
652    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    /// Expires a stable batch of due timed keepsakes.
679    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    /// Upserts a simple fulfillment counter projection.
714    #[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}