Skip to main content

keepsake_sqlx/
repository.rs

1//! Postgres repository implementation.
2
3use 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
29/// Result alias for SQL repository operations.
30pub type RepositoryResult<T> = std::result::Result<T, RepositoryError>;
31
32/// SQL repository errors.
33#[derive(Debug, thiserror::Error)]
34pub enum RepositoryError {
35    /// `SQLx` returned an error.
36    #[error(transparent)]
37    Sqlx(#[from] sqlx::Error),
38
39    /// Migration failed.
40    #[cfg(feature = "migrations")]
41    #[error(transparent)]
42    Migration(#[from] sqlx::migrate::MigrateError),
43
44    /// JSON policy could not be encoded or decoded.
45    #[error(transparent)]
46    Json(#[from] serde_json::Error),
47
48    /// A Keepsake core model could not be built.
49    #[error(transparent)]
50    Keepsake(#[from] keepsake::KeepsakeError),
51
52    /// A command tried to mutate a disabled relation.
53    #[error("relation {relation_id} is disabled")]
54    RelationDisabled {
55        /// Disabled relation id.
56        relation_id: Uuid,
57    },
58
59    /// A typed relation spec conflicts with an existing natural-key row.
60    #[error(
61        "relation spec {kind}/{name} expected id {expected_relation_id}, but stored relation uses {stored_relation_id}"
62    )]
63    RelationSpecIdMismatch {
64        /// Relation kind.
65        kind: String,
66        /// Relation name.
67        name: String,
68        /// Relation id declared by the typed spec.
69        expected_relation_id: Uuid,
70        /// Existing stored relation id for the same natural key.
71        stored_relation_id: Uuid,
72    },
73
74    /// A batch or scan limit was outside the accepted range.
75    #[error("limit {limit} is outside the accepted range 1..={max}")]
76    InvalidLimit {
77        /// Provided limit.
78        limit: i64,
79        /// Maximum accepted limit.
80        max: i64,
81    },
82
83    /// A row contained an unknown lifecycle state.
84    #[error("unknown lifecycle state {state}")]
85    InvalidLifecycleState {
86        /// Stored state value.
87        state: String,
88    },
89}
90
91/// `SQLx`-backed keepsake repository.
92#[derive(Debug, Clone)]
93pub struct KeepsakeRepository<C = NoopRelationCache> {
94    pool: PgPool,
95    relation_cache: C,
96}
97
98impl KeepsakeRepository<NoopRelationCache> {
99    /// Creates a repository from a Postgres pool.
100    #[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    /// Creates a timestamp-scoped repository view.
114    ///
115    /// Use this at request or job boundaries to keep one explicit clock read while
116    /// avoiding repeated timestamp plumbing through related repository calls.
117    pub const fn at(&self, at: DateTime<Utc>) -> TimedKeepsakeRepository<'_, C> {
118        TimedKeepsakeRepository {
119            repository: self,
120            at,
121        }
122    }
123
124    /// Enables relation definition caching for read helper methods.
125    #[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    /// Enables local in-process relation definition caching for read helper methods.
137    #[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    /// Runs embedded migrations.
147    #[cfg(feature = "migrations")]
148    pub async fn migrate(&self) -> RepositoryResult<()> {
149        MIGRATOR.run(&self.pool).await?;
150        Ok(())
151    }
152
153    /// Inserts or updates a relation definition by its natural relation key.
154    ///
155    /// If a relation already exists for the same kind/name, its stable id is preserved and
156    /// the returned definition contains the existing id.
157    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    /// Inserts or updates a typed relation spec by its natural relation key.
189    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    /// Looks up a relation definition by stable id.
248    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    /// Looks up a relation definition by its natural relation key.
264    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    /// Enables or disables a relation.
280    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    /// Applies a keepsake relation idempotently.
343    ///
344    /// If an active keepsake already exists for the subject and relation, the existing
345    /// row is returned with `duplicate_prevented` set to true, even if the relation
346    /// has since been disabled. Disabled relations reject new non-duplicate applies.
347    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    /// Applies a typed keepsake relation idempotently.
410    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    /// Applies a keepsake relation with empty metadata.
423    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    /// Applies a typed keepsake relation with empty metadata.
433    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    /// Revokes an active keepsake.
445    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    /// Returns active keepsakes for a subject.
461    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    /// Returns active keepsakes for a subject with their relation definitions.
485    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    /// Returns active keepsakes for a subject, filtered by relation keys.
529    ///
530    /// This is the bounded variant of [`Self::active_relations_for_subject`] for
531    /// request paths that know the small set of relation keys they care about.
532    /// Missing keys are ignored, and disabled relation definitions are still
533    /// returned when their keepsake is active.
534    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    /// Scans active memberships for a relation in stable order.
603    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    /// Scans active memberships after a keyset cursor in stable order.
613    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    /// Lists due timed expiry candidates in stable batch order.
649    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    /// Expires a stable batch of due timed keepsakes.
676    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    /// Upserts a simple fulfillment counter projection.
711    #[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/// Timestamp-scoped repository view.
740///
741/// This wrapper does not read the system clock. Callers choose the timestamp once
742/// at an operation boundary, then use the forwarding methods to keep related
743/// writes and expiry scans on the same deterministic instant.
744#[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    /// Returns the repository backing this timestamp-scoped view.
755    #[must_use]
756    pub const fn repository(&self) -> &'repo KeepsakeRepository<C> {
757        self.repository
758    }
759
760    /// Returns the timestamp applied by forwarding methods.
761    #[must_use]
762    pub const fn timestamp(&self) -> DateTime<Utc> {
763        self.at
764    }
765
766    /// Inserts or updates a relation definition using this view's timestamp.
767    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    /// Inserts or updates a typed relation spec using this view's timestamp.
775    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    /// Enables or disables a relation using this view's timestamp.
783    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    /// Applies a keepsake relation idempotently using this view's timestamp.
794    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    /// Applies a typed keepsake relation idempotently using this view's timestamp.
806    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    /// Applies a keepsake relation with empty metadata using this view's timestamp.
820    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    /// Applies a typed keepsake relation with empty metadata using this view's timestamp.
831    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    /// Revokes an active keepsake using this view's timestamp.
844    pub async fn revoke(&self, keepsake_id: Uuid) -> RepositoryResult<bool> {
845        self.repository.revoke(keepsake_id, self.at).await
846    }
847
848    /// Lists due timed expiry candidates using this view's timestamp.
849    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    /// Expires a stable batch of due timed keepsakes using this view's timestamp.
857    pub async fn expire_due_timed(&self, limit: i64) -> RepositoryResult<u64> {
858        self.repository.expire_due_timed(self.at, limit).await
859    }
860
861    /// Upserts a simple fulfillment counter projection using this view's timestamp.
862    #[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/// Adapter for relation definition caching.
932#[async_trait::async_trait]
933pub trait RelationCache: Send + Sync + std::fmt::Debug {
934    /// Gets a cached relation by stable id.
935    async fn get_by_id(&self, relation_id: RelationId) -> Option<RelationDefinition>;
936
937    /// Gets a cached relation by natural relation key.
938    async fn get_by_key(&self, key: &RelationKey) -> Option<RelationDefinition>;
939
940    /// Stores or refreshes a relation definition.
941    async fn store(&self, relation: &RelationDefinition);
942
943    /// Removes cached entries for a relation id.
944    async fn remove_by_id(&self, relation_id: RelationId);
945}
946
947/// Relation cache implementation that never stores entries.
948#[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/// Configuration for local in-process relation definition caching.
967#[cfg(feature = "cache")]
968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
969pub struct LocalRelationCacheConfig {
970    /// Time before a cached relation definition must be refreshed from Postgres.
971    pub ttl: Duration,
972}
973
974#[cfg(feature = "cache")]
975impl LocalRelationCacheConfig {
976    /// Creates a local relation cache configuration.
977    #[must_use]
978    pub const fn new(ttl: Duration) -> Self {
979        Self { ttl }
980    }
981}
982
983/// Local in-process relation definition cache.
984#[cfg(feature = "cache")]
985#[derive(Debug, Clone)]
986pub struct LocalRelationCache {
987    config: LocalRelationCacheConfig,
988    // Local cache handles may be cloned or shared across repository clones.
989    // Locks protect a small in-process map and are never held across `.await`.
990    // Cross-pod invalidation belongs in another `RelationCache` adapter.
991    state: Arc<RwLock<LocalRelationCacheState>>,
992}
993
994#[cfg(feature = "cache")]
995impl LocalRelationCache {
996    /// Creates a local in-process relation definition cache.
997    #[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/// Keyset cursor for active relation membership scans.
1067#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1068pub struct MembershipCursor {
1069    /// Last seen subject kind.
1070    pub subject_kind: String,
1071    /// Last seen subject id.
1072    pub subject_id: String,
1073    /// Last seen keepsake id.
1074    pub keepsake_id: Uuid,
1075}
1076
1077impl MembershipCursor {
1078    /// Creates a cursor positioned after a returned keepsake.
1079    #[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/// Result of an apply operation.
1090#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1091pub struct AppliedKeepsake {
1092    /// Created keepsake, or the existing active keepsake for duplicate applies.
1093    pub keepsake: Keepsake,
1094    /// Whether a duplicate active keepsake was prevented.
1095    pub duplicate_prevented: bool,
1096}
1097
1098/// Active keepsake with its relation definition.
1099#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1100pub struct ActiveRelation {
1101    /// Active keepsake.
1102    pub keepsake: Keepsake,
1103    /// Stored relation definition for the keepsake.
1104    pub relation: RelationDefinition,
1105}
1106
1107/// Due timed expiry candidate.
1108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)]
1109pub struct TimedExpiryCandidate {
1110    /// Keepsake id.
1111    pub keepsake_id: Uuid,
1112    /// Relation id.
1113    pub relation_id: Uuid,
1114    /// Subject kind.
1115    pub subject_kind: String,
1116    /// Subject id.
1117    pub subject_id: String,
1118    /// Due timestamp.
1119    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}