keepsake_sqlx/
repository.rs1use 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
34pub type RepositoryResult<T> = core::result::Result<T, RepositoryError>;
36
37#[derive(Debug, thiserror::Error)]
39pub enum RepositoryError {
40 #[error(transparent)]
42 Sqlx(#[from] sqlx::Error),
43
44 #[cfg(feature = "migrations")]
46 #[error(transparent)]
47 Migration(#[from] sqlx::migrate::MigrateError),
48
49 #[error(transparent)]
51 Json(#[from] serde_json::Error),
52
53 #[error(transparent)]
55 Keepsake(#[from] keepsake::KeepsakeError),
56
57 #[error("relation {relation_id} is disabled")]
59 RelationDisabled {
60 relation_id: Uuid,
62 },
63
64 #[error(
66 "relation spec {kind}/{name} expected id {expected_relation_id}, but stored relation uses {stored_relation_id}"
67 )]
68 RelationSpecIdMismatch {
69 kind: String,
71 name: String,
73 expected_relation_id: Uuid,
75 stored_relation_id: Uuid,
77 },
78
79 #[error("limit {limit} is outside the accepted range 1..={max}")]
81 InvalidLimit {
82 limit: i64,
84 max: i64,
86 },
87
88 #[error("unknown lifecycle state {state}")]
90 InvalidLifecycleState {
91 state: String,
93 },
94}
95
96#[derive(Debug, Clone)]
98pub struct KeepsakeRepository<C = NoopRelationCache> {
99 pool: PgPool,
100 relation_cache: C,
101}
102
103impl KeepsakeRepository<NoopRelationCache> {
104 #[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 pub const fn at(&self, at: DateTime<Utc>) -> TimedKeepsakeRepository<'_, C> {
123 TimedKeepsakeRepository {
124 repository: self,
125 at,
126 }
127 }
128
129 #[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 #[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 #[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}