Skip to main content

keepsake_sqlx/
repository.rs

1//! Postgres repository implementation.
2
3use chrono::{DateTime, Utc};
4use sqlx::PgPool;
5use uuid::Uuid;
6
7#[cfg(feature = "migrations")]
8use sqlx::migrate::Migrator;
9
10mod audit;
11mod cache;
12mod expiry;
13mod mutation;
14mod query;
15mod relation;
16mod rows;
17mod timed;
18mod types;
19
20#[cfg(feature = "cache")]
21pub use cache::{LocalRelationCache, LocalRelationCacheConfig};
22pub use cache::{NoopRelationCache, RelationCache};
23pub use keepsake::ActiveRelation;
24pub use timed::TimedKeepsakeRepository;
25pub use types::{AppliedKeepsake, MembershipCursor, TimedExpiryCandidate};
26
27use rows::{ActiveRelationRow, AppliedKeepsakeRow, AppliedKeepsakeWriteRow, RelationRow};
28
29#[cfg(feature = "migrations")]
30static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
31
32const MAX_BATCH_LIMIT: i64 = 10_000;
33
34/// Result alias for SQL repository operations.
35pub type RepositoryResult<T> = core::result::Result<T, RepositoryError>;
36
37/// SQL repository errors.
38#[derive(Debug, thiserror::Error)]
39pub enum RepositoryError {
40    /// `SQLx` returned an error.
41    #[error(transparent)]
42    Sqlx(#[from] sqlx::Error),
43
44    /// Migration failed.
45    #[cfg(feature = "migrations")]
46    #[error(transparent)]
47    Migration(#[from] sqlx::migrate::MigrateError),
48
49    /// JSON policy could not be encoded or decoded.
50    #[error(transparent)]
51    Json(#[from] serde_json::Error),
52
53    /// A Keepsake core model could not be built.
54    #[error(transparent)]
55    Keepsake(#[from] keepsake::KeepsakeError),
56
57    /// A command tried to mutate a disabled relation.
58    #[error("relation {relation_id} is disabled")]
59    RelationDisabled {
60        /// Disabled relation id.
61        relation_id: Uuid,
62    },
63
64    /// A typed relation spec conflicts with an existing natural-key row.
65    #[error(
66        "relation spec {kind}/{name} expected id {expected_relation_id}, but stored relation uses {stored_relation_id}"
67    )]
68    RelationSpecIdMismatch {
69        /// Relation kind.
70        kind: String,
71        /// Relation name.
72        name: String,
73        /// Relation id declared by the typed spec.
74        expected_relation_id: Uuid,
75        /// Existing stored relation id for the same natural key.
76        stored_relation_id: Uuid,
77    },
78
79    /// A batch or scan limit was outside the accepted range.
80    #[error("limit {limit} is outside the accepted range 1..={max}")]
81    InvalidLimit {
82        /// Provided limit.
83        limit: i64,
84        /// Maximum accepted limit.
85        max: i64,
86    },
87
88    /// A row contained an unknown lifecycle state.
89    #[error("unknown lifecycle state {state}")]
90    InvalidLifecycleState {
91        /// Stored state value.
92        state: String,
93    },
94}
95
96/// `SQLx`-backed keepsake repository.
97#[derive(Debug, Clone)]
98pub struct KeepsakeRepository<C = NoopRelationCache> {
99    pool: PgPool,
100    relation_cache: C,
101}
102
103impl KeepsakeRepository<NoopRelationCache> {
104    /// Creates a repository from a Postgres pool.
105    #[must_use]
106    pub const fn new(pool: PgPool) -> Self {
107        Self {
108            pool,
109            relation_cache: NoopRelationCache,
110        }
111    }
112}
113
114impl<C> KeepsakeRepository<C>
115where
116    C: RelationCache,
117{
118    /// Creates a timestamp-scoped repository view.
119    ///
120    /// Use this at request or job boundaries to keep one explicit clock read while
121    /// avoiding repeated timestamp plumbing through related repository calls.
122    pub const fn at(&self, at: DateTime<Utc>) -> TimedKeepsakeRepository<'_, C> {
123        TimedKeepsakeRepository {
124            repository: self,
125            at,
126        }
127    }
128
129    /// Enables relation definition caching for read helper methods.
130    #[must_use]
131    pub fn with_relation_cache<Next>(self, cache: Next) -> KeepsakeRepository<Next>
132    where
133        Next: RelationCache,
134    {
135        KeepsakeRepository {
136            pool: self.pool,
137            relation_cache: cache,
138        }
139    }
140
141    /// Enables local in-process relation definition caching for read helper methods.
142    #[cfg(feature = "cache")]
143    #[must_use]
144    pub fn with_local_relation_cache(
145        self,
146        config: LocalRelationCacheConfig,
147    ) -> KeepsakeRepository<LocalRelationCache> {
148        self.with_relation_cache(LocalRelationCache::new(config))
149    }
150
151    /// Runs embedded migrations.
152    #[cfg(feature = "migrations")]
153    pub async fn migrate(&self) -> RepositoryResult<()> {
154        MIGRATOR.run(&self.pool).await?;
155        Ok(())
156    }
157}
158
159fn validate_limit(limit: i64) -> RepositoryResult<i64> {
160    if (1..=MAX_BATCH_LIMIT).contains(&limit) {
161        Ok(limit)
162    } else {
163        Err(RepositoryError::InvalidLimit {
164            limit,
165            max: MAX_BATCH_LIMIT,
166        })
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use chrono::DateTime;
173    use keepsake::SubjectRef;
174    use sqlx::postgres::PgPoolOptions;
175
176    use super::rows::parse_state;
177    use super::*;
178
179    fn ts(value: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
180        DateTime::parse_from_rfc3339(value).map(|timestamp| timestamp.with_timezone(&Utc))
181    }
182
183    #[derive(Debug, thiserror::Error)]
184    enum TestError {
185        #[error(transparent)]
186        Chrono(#[from] chrono::ParseError),
187
188        #[error(transparent)]
189        Keepsake(#[from] keepsake::KeepsakeError),
190
191        #[error(transparent)]
192        Repository(#[from] RepositoryError),
193
194        #[error(transparent)]
195        SerdeJson(#[from] serde_json::Error),
196
197        #[error(transparent)]
198        Sqlx(#[from] sqlx::Error),
199    }
200
201    #[tokio::test]
202    async fn timestamp_scoped_repository_reuses_explicit_timestamp() -> Result<(), TestError> {
203        let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/keepsake")?;
204        let repo = KeepsakeRepository::new(pool);
205        let at = ts("2026-01-02T00:00:00Z")?;
206        let timed_repo = repo.at(at);
207
208        assert_eq!(timed_repo.timestamp(), at);
209        Ok(())
210    }
211
212    #[tokio::test]
213    async fn active_relations_for_subject_by_keys_short_circuits_empty_keys()
214    -> Result<(), TestError> {
215        let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/keepsake")?;
216        let repo = KeepsakeRepository::new(pool);
217        let subject = SubjectRef::new("account", "acct_123")?;
218
219        let active = repo
220            .active_relations_for_subject_by_keys(&subject, &[])
221            .await?;
222
223        assert!(active.is_empty());
224        Ok(())
225    }
226
227    #[test]
228    fn membership_cursor_serializes_for_api_boundaries() -> RepositoryResult<()> {
229        let cursor = MembershipCursor {
230            subject_kind: "account".to_owned(),
231            subject_id: "acct_123".to_owned(),
232            keepsake_id: Uuid::nil(),
233        };
234
235        let encoded = serde_json::to_string(&cursor)?;
236        let decoded = serde_json::from_str::<MembershipCursor>(&encoded)?;
237
238        assert_eq!(decoded, cursor);
239        Ok(())
240    }
241
242    #[test]
243    fn timed_expiry_candidate_serializes_with_stable_field_names() -> Result<(), TestError> {
244        let candidate = TimedExpiryCandidate {
245            keepsake_id: Uuid::nil(),
246            relation_id: Uuid::nil(),
247            subject_kind: "account".to_owned(),
248            subject_id: "acct_123".to_owned(),
249            due_at: ts("2026-01-02T00:00:00Z")?,
250        };
251
252        let encoded = serde_json::to_value(&candidate)?;
253
254        assert_eq!(
255            encoded,
256            serde_json::json!({
257                "keepsake_id": "00000000-0000-0000-0000-000000000000",
258                "relation_id": "00000000-0000-0000-0000-000000000000",
259                "subject_kind": "account",
260                "subject_id": "acct_123",
261                "due_at": "2026-01-02T00:00:00Z"
262            })
263        );
264        assert_eq!(
265            serde_json::from_value::<TimedExpiryCandidate>(encoded)?,
266            candidate
267        );
268        Ok(())
269    }
270
271    #[test]
272    fn parse_state_rejects_unknown_values() {
273        let error = parse_state("archived".to_owned())
274            .map(|_| ())
275            .map_err(|error| error.to_string());
276
277        assert_eq!(error, Err("unknown lifecycle state archived".to_owned()));
278    }
279}