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