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 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
33pub type RepositoryResult<T> = core::result::Result<T, RepositoryError>;
35
36#[derive(Debug, thiserror::Error)]
38pub enum RepositoryError {
39 #[error(transparent)]
41 Sqlx(#[from] sqlx::Error),
42
43 #[cfg(feature = "migrations")]
45 #[error(transparent)]
46 Migration(#[from] sqlx::migrate::MigrateError),
47
48 #[error(transparent)]
50 Json(#[from] serde_json::Error),
51
52 #[error(transparent)]
54 Keepsake(#[from] keepsake::KeepsakeError),
55
56 #[error("relation {relation_id} is disabled")]
58 RelationDisabled {
59 relation_id: Uuid,
61 },
62
63 #[error(
65 "relation spec {kind}/{name} expected id {expected_relation_id}, but stored relation uses {stored_relation_id}"
66 )]
67 RelationSpecIdMismatch {
68 kind: String,
70 name: String,
72 expected_relation_id: Uuid,
74 stored_relation_id: Uuid,
76 },
77
78 #[error("limit {limit} is outside the accepted range 1..={max}")]
80 InvalidLimit {
81 limit: i64,
83 max: i64,
85 },
86
87 #[error("unknown lifecycle state {state}")]
89 InvalidLifecycleState {
90 state: String,
92 },
93}
94
95#[derive(Debug, Clone)]
97pub struct KeepsakeRepository<C = NoopRelationCache> {
98 pool: PgPool,
99 relation_cache: C,
100}
101
102impl KeepsakeRepository<NoopRelationCache> {
103 #[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 pub const fn at(&self, at: DateTime<Utc>) -> TimedKeepsakeRepository<'_, C> {
122 TimedKeepsakeRepository {
123 repository: self,
124 at,
125 }
126 }
127
128 #[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 #[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 #[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}