1use chrono::{DateTime, Utc};
4use sqlx::Pool;
5use uuid::Uuid;
6
7#[cfg(feature = "migrations")]
8use sqlx::migrate::Migrator;
9
10#[cfg(feature = "postgres")]
11mod audit;
12mod backend;
13mod cache;
14#[cfg(feature = "postgres")]
15mod expiry;
16#[cfg(feature = "postgres")]
17mod mutation;
18#[cfg(feature = "mysql")]
19mod mysql;
20#[cfg(feature = "postgres")]
21mod query;
22#[cfg(feature = "postgres")]
23mod relation;
24#[cfg(feature = "postgres")]
25mod rows;
26#[cfg(feature = "sqlite")]
27mod sqlite;
28mod timed;
29mod types;
30
31pub use backend::KeepsakeSqlxBackend;
32#[cfg(feature = "mysql")]
33pub use backend::MySqlBackend;
34#[cfg(feature = "postgres")]
35pub use backend::PostgresBackend;
36#[cfg(feature = "sqlite")]
37pub use backend::SqliteBackend;
38#[cfg(feature = "cache")]
39pub use cache::{LocalRelationCache, LocalRelationCacheConfig};
40pub use cache::{NoopRelationCache, RelationCache};
41pub use keepsake::ActiveRelation;
42#[cfg(feature = "postgres")]
43pub use timed::TimedKeepsakeRepository;
44#[cfg(feature = "mysql")]
45pub use timed::TimedMySqlKeepsakeRepository;
46#[cfg(feature = "sqlite")]
47pub use timed::TimedSqliteKeepsakeRepository;
48pub use timed::TimedSqlxKeepsakeRepository;
49pub use types::{
50 AppliedKeepsake, FulfilledExpiryCandidate, MembershipCursor, TimedExpiryCandidate,
51};
52
53use backend::BackendMarker;
54#[cfg(feature = "postgres")]
55use rows::{ActiveRelationRow, AppliedKeepsakeRow, AppliedKeepsakeWriteRow, RelationRow};
56
57#[cfg(all(feature = "migrations", feature = "postgres"))]
58static POSTGRES_MIGRATOR: Migrator = sqlx::migrate!("./migrations/postgres");
59
60#[cfg(all(feature = "migrations", feature = "sqlite"))]
61static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("./migrations/sqlite");
62
63#[cfg(all(feature = "migrations", feature = "mysql"))]
64static MYSQL_MIGRATOR: Migrator = sqlx::migrate!("./migrations/mysql");
65
66#[allow(dead_code)]
67const MAX_BATCH_LIMIT: i64 = 10_000;
68
69pub type RepositoryResult<T> = core::result::Result<T, RepositoryError>;
71
72#[non_exhaustive]
74#[derive(Debug, thiserror::Error)]
75pub enum RepositoryError {
76 #[error(transparent)]
78 Sqlx(#[from] sqlx::Error),
79
80 #[cfg(feature = "migrations")]
82 #[error(transparent)]
83 Migration(#[from] sqlx::migrate::MigrateError),
84
85 #[error(transparent)]
87 Json(#[from] serde_json::Error),
88
89 #[error(transparent)]
91 Keepsake(#[from] keepsake::KeepsakeError),
92
93 #[error("schema backend mismatch: expected {expected}, found {actual}")]
95 BackendMismatch {
96 expected: &'static str,
98 actual: String,
100 },
101
102 #[error("relation {relation_id} is disabled")]
104 RelationDisabled {
105 relation_id: Uuid,
107 },
108
109 #[error(
111 "relation spec {kind}/{name} expected id {expected_relation_id}, but stored relation uses {stored_relation_id}"
112 )]
113 RelationSpecIdMismatch {
114 kind: String,
116 name: String,
118 expected_relation_id: Uuid,
120 stored_relation_id: Uuid,
122 },
123
124 #[error("relation definition {relation_id} was not found")]
126 RelationDefinitionMissing {
127 relation_id: Uuid,
129 },
130
131 #[error("limit {limit} is outside the accepted range 1..={max}")]
133 InvalidLimit {
134 limit: i64,
136 max: i64,
138 },
139
140 #[error("unknown lifecycle state {state}")]
142 InvalidLifecycleState {
143 state: String,
145 },
146}
147
148#[derive(Debug)]
150pub struct SqlxKeepsakeRepository<B, C = NoopRelationCache>
151where
152 B: KeepsakeSqlxBackend,
153{
154 pool: Pool<B::Database>,
155 #[allow(dead_code)]
156 relation_cache: C,
157 backend: BackendMarker<B>,
158}
159
160impl<B, C> Clone for SqlxKeepsakeRepository<B, C>
161where
162 B: KeepsakeSqlxBackend,
163 C: Clone,
164{
165 fn clone(&self) -> Self {
166 Self {
167 pool: self.pool.clone(),
168 relation_cache: self.relation_cache.clone(),
169 backend: self.backend,
170 }
171 }
172}
173
174#[cfg(feature = "postgres")]
176pub type PostgresKeepsakeRepository<C = NoopRelationCache> =
177 SqlxKeepsakeRepository<PostgresBackend, C>;
178
179#[cfg(feature = "postgres")]
181pub type KeepsakeRepository<C = NoopRelationCache> = PostgresKeepsakeRepository<C>;
182
183#[cfg(feature = "sqlite")]
185pub type SqliteKeepsakeRepository<C = NoopRelationCache> = SqlxKeepsakeRepository<SqliteBackend, C>;
186
187#[cfg(feature = "mysql")]
189pub type MySqlKeepsakeRepository<C = NoopRelationCache> = SqlxKeepsakeRepository<MySqlBackend, C>;
190
191#[cfg(feature = "postgres")]
192impl PostgresKeepsakeRepository<NoopRelationCache> {
193 #[must_use]
195 pub const fn new(pool: sqlx::PgPool) -> Self {
196 Self {
197 pool,
198 relation_cache: NoopRelationCache,
199 backend: BackendMarker::new(),
200 }
201 }
202}
203
204#[cfg(feature = "sqlite")]
205impl SqliteKeepsakeRepository<NoopRelationCache> {
206 #[must_use]
208 pub const fn new(pool: sqlx::SqlitePool) -> Self {
209 Self {
210 pool,
211 relation_cache: NoopRelationCache,
212 backend: BackendMarker::new(),
213 }
214 }
215}
216
217#[cfg(feature = "mysql")]
218impl MySqlKeepsakeRepository<NoopRelationCache> {
219 #[must_use]
221 pub const fn new(pool: sqlx::MySqlPool) -> Self {
222 Self {
223 pool,
224 relation_cache: NoopRelationCache,
225 backend: BackendMarker::new(),
226 }
227 }
228}
229
230impl<B, C> SqlxKeepsakeRepository<B, C>
231where
232 B: KeepsakeSqlxBackend,
233 C: RelationCache,
234{
235 pub const fn at(&self, at: DateTime<Utc>) -> TimedSqlxKeepsakeRepository<'_, B, C> {
240 TimedSqlxKeepsakeRepository {
241 repository: self,
242 at,
243 }
244 }
245
246 #[must_use]
248 pub fn with_relation_cache<Next>(self, cache: Next) -> SqlxKeepsakeRepository<B, Next>
249 where
250 Next: RelationCache,
251 {
252 SqlxKeepsakeRepository {
253 pool: self.pool,
254 relation_cache: cache,
255 backend: self.backend,
256 }
257 }
258
259 #[cfg(feature = "cache")]
265 #[must_use]
266 pub fn with_local_relation_cache(
267 self,
268 config: LocalRelationCacheConfig,
269 ) -> SqlxKeepsakeRepository<B, LocalRelationCache> {
270 self.with_relation_cache(LocalRelationCache::new(config))
271 }
272}
273
274#[cfg(all(feature = "postgres", feature = "migrations"))]
275impl<C> PostgresKeepsakeRepository<C>
276where
277 C: RelationCache,
278{
279 pub async fn migrate(&self) -> RepositoryResult<()> {
281 postgres_schema_preflight(&self.pool).await?;
282 POSTGRES_MIGRATOR.run(&self.pool).await?;
283 Ok(())
284 }
285}
286
287#[cfg(all(feature = "sqlite", feature = "migrations"))]
288impl<C> SqliteKeepsakeRepository<C>
289where
290 C: RelationCache,
291{
292 pub async fn migrate(&self) -> RepositoryResult<()> {
294 sqlite_schema_preflight(&self.pool).await?;
295 SQLITE_MIGRATOR.run(&self.pool).await?;
296 Ok(())
297 }
298}
299
300#[cfg(all(feature = "mysql", feature = "migrations"))]
301impl<C> MySqlKeepsakeRepository<C>
302where
303 C: RelationCache,
304{
305 pub async fn migrate(&self) -> RepositoryResult<()> {
307 mysql_schema_preflight(&self.pool).await?;
308 MYSQL_MIGRATOR.run(&self.pool).await?;
309 Ok(())
310 }
311}
312
313#[allow(dead_code)]
314fn validate_limit(limit: i64) -> RepositoryResult<i64> {
315 if (1..=MAX_BATCH_LIMIT).contains(&limit) {
316 Ok(limit)
317 } else {
318 Err(RepositoryError::InvalidLimit {
319 limit,
320 max: MAX_BATCH_LIMIT,
321 })
322 }
323}
324
325#[cfg(all(feature = "postgres", feature = "migrations"))]
326async fn postgres_schema_preflight(pool: &sqlx::PgPool) -> RepositoryResult<()> {
327 let metadata_table = sqlx::query_scalar::<_, Option<String>>(
328 r"
329 select to_regclass('public.keepsake_schema_metadata')::text
330 ",
331 )
332 .fetch_one(pool)
333 .await?;
334
335 if metadata_table.is_none() {
336 return postgres_unmarked_schema_preflight(pool).await;
337 }
338
339 let backend = sqlx::query_scalar::<_, Option<String>>(
340 r"
341 select value
342 from keepsake_schema_metadata
343 where key = 'backend'
344 ",
345 )
346 .fetch_one(pool)
347 .await?;
348
349 match backend.as_deref() {
350 Some(PostgresBackend::NAME) | None => Ok(()),
351 Some(actual) => Err(RepositoryError::BackendMismatch {
352 expected: PostgresBackend::NAME,
353 actual: actual.to_owned(),
354 }),
355 }
356}
357
358#[cfg(all(feature = "postgres", feature = "migrations"))]
359async fn postgres_unmarked_schema_preflight(pool: &sqlx::PgPool) -> RepositoryResult<()> {
360 let user_table_count = sqlx::query_scalar::<_, i64>(
361 r"
362 select count(*)
363 from information_schema.tables
364 where table_schema = 'public'
365 and table_type = 'BASE TABLE'
366 ",
367 )
368 .fetch_one(pool)
369 .await?;
370
371 if user_table_count == 0 {
372 return Ok(());
373 }
374
375 let has_keepsake_tables = sqlx::query_scalar::<_, bool>(
376 r"
377 select to_regclass('public.keepsake_relation_definitions') is not null
378 and to_regclass('public.keepsakes') is not null
379 and to_regclass('public._sqlx_migrations') is not null
380 ",
381 )
382 .fetch_one(pool)
383 .await?;
384 if !has_keepsake_tables {
385 return Err(RepositoryError::BackendMismatch {
386 expected: PostgresBackend::NAME,
387 actual: "unmarked non-empty schema".to_owned(),
388 });
389 }
390
391 let known_migrations = sqlx::query_scalar::<_, i64>(
392 r"
393 select count(*)
394 from _sqlx_migrations
395 where version in (1, 2)
396 ",
397 )
398 .fetch_one(pool)
399 .await?;
400
401 if known_migrations == 2 {
402 Ok(())
403 } else {
404 Err(RepositoryError::BackendMismatch {
405 expected: PostgresBackend::NAME,
406 actual: "unmarked unknown migration history".to_owned(),
407 })
408 }
409}
410
411#[cfg(all(feature = "sqlite", feature = "migrations"))]
412async fn sqlite_schema_preflight(pool: &sqlx::SqlitePool) -> RepositoryResult<()> {
413 let metadata_table = sqlx::query_scalar::<_, Option<String>>(
414 r"
415 select name
416 from sqlite_master
417 where type = 'table' and name = 'keepsake_schema_metadata'
418 ",
419 )
420 .fetch_optional(pool)
421 .await?
422 .flatten();
423
424 if metadata_table.is_some() {
425 let backend = sqlx::query_scalar::<_, Option<String>>(
426 r"
427 select value
428 from keepsake_schema_metadata
429 where key = 'backend'
430 ",
431 )
432 .fetch_one(pool)
433 .await?;
434 return match backend.as_deref() {
435 Some(SqliteBackend::NAME) | None => Ok(()),
436 Some(actual) => Err(RepositoryError::BackendMismatch {
437 expected: SqliteBackend::NAME,
438 actual: actual.to_owned(),
439 }),
440 };
441 }
442
443 let existing_tables = sqlx::query_scalar::<_, i64>(
444 r"
445 select count(*)
446 from sqlite_master
447 where type = 'table'
448 and name not like 'sqlite_%'
449 ",
450 )
451 .fetch_one(pool)
452 .await?;
453
454 if existing_tables == 0 {
455 Ok(())
456 } else {
457 Err(RepositoryError::BackendMismatch {
458 expected: SqliteBackend::NAME,
459 actual: "unmarked non-empty schema".to_owned(),
460 })
461 }
462}
463
464#[cfg(all(feature = "mysql", feature = "migrations"))]
465async fn mysql_schema_preflight(pool: &sqlx::MySqlPool) -> RepositoryResult<()> {
466 let metadata_table = sqlx::query_scalar::<_, Option<String>>(
467 r"
468 select table_name
469 from information_schema.tables
470 where table_schema = database()
471 and table_name = 'keepsake_schema_metadata'
472 ",
473 )
474 .fetch_optional(pool)
475 .await?
476 .flatten();
477
478 if metadata_table.is_some() {
479 let backend = sqlx::query_scalar::<_, Option<String>>(
480 r"
481 select value
482 from keepsake_schema_metadata
483 where `key` = 'backend'
484 ",
485 )
486 .fetch_one(pool)
487 .await?;
488 return match backend.as_deref() {
489 Some(MySqlBackend::NAME) | None => Ok(()),
490 Some(actual) => Err(RepositoryError::BackendMismatch {
491 expected: MySqlBackend::NAME,
492 actual: actual.to_owned(),
493 }),
494 };
495 }
496
497 let existing_tables = sqlx::query_scalar::<_, i64>(
498 r"
499 select count(*)
500 from information_schema.tables
501 where table_schema = database()
502 ",
503 )
504 .fetch_one(pool)
505 .await?;
506
507 if existing_tables == 0 {
508 Ok(())
509 } else {
510 Err(RepositoryError::BackendMismatch {
511 expected: MySqlBackend::NAME,
512 actual: "unmarked non-empty schema".to_owned(),
513 })
514 }
515}
516
517#[cfg(all(test, feature = "postgres"))]
518mod tests {
519 use chrono::DateTime;
520 use keepsake::SubjectRef;
521 use sqlx::postgres::PgPoolOptions;
522
523 use super::rows::parse_state;
524 use super::*;
525
526 fn ts(value: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
527 DateTime::parse_from_rfc3339(value).map(|timestamp| timestamp.with_timezone(&Utc))
528 }
529
530 #[derive(Debug, thiserror::Error)]
531 enum TestError {
532 #[error(transparent)]
533 Chrono(#[from] chrono::ParseError),
534
535 #[error(transparent)]
536 Keepsake(#[from] keepsake::KeepsakeError),
537
538 #[error(transparent)]
539 Repository(#[from] RepositoryError),
540
541 #[error(transparent)]
542 SerdeJson(#[from] serde_json::Error),
543
544 #[error(transparent)]
545 Sqlx(#[from] sqlx::Error),
546 }
547
548 #[tokio::test]
549 async fn timestamp_scoped_repository_reuses_explicit_timestamp() -> Result<(), TestError> {
550 let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/keepsake")?;
551 let repo = KeepsakeRepository::new(pool);
552 let at = ts("2026-01-02T00:00:00Z")?;
553 let timed_repo = repo.at(at);
554
555 assert_eq!(timed_repo.timestamp(), at);
556 Ok(())
557 }
558
559 #[tokio::test]
560 async fn active_relations_for_subject_by_keys_short_circuits_empty_keys()
561 -> Result<(), TestError> {
562 let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/keepsake")?;
563 let repo = KeepsakeRepository::new(pool);
564 let subject = SubjectRef::new("account", "acct_123")?;
565
566 let active = repo
567 .active_relations_for_subject_by_keys(&subject, &[])
568 .await?;
569
570 assert!(active.is_empty());
571 Ok(())
572 }
573
574 #[test]
575 fn membership_cursor_serializes_for_api_boundaries() -> RepositoryResult<()> {
576 let cursor = MembershipCursor {
577 subject_kind: "account".to_owned(),
578 subject_id: "acct_123".to_owned(),
579 keepsake_id: Uuid::nil(),
580 };
581
582 let encoded = serde_json::to_string(&cursor)?;
583 let decoded = serde_json::from_str::<MembershipCursor>(&encoded)?;
584
585 assert_eq!(decoded, cursor);
586 Ok(())
587 }
588
589 #[test]
590 fn timed_expiry_candidate_serializes_with_stable_field_names() -> Result<(), TestError> {
591 let candidate = TimedExpiryCandidate {
592 keepsake_id: Uuid::nil(),
593 relation_id: Uuid::nil(),
594 subject_kind: "account".to_owned(),
595 subject_id: "acct_123".to_owned(),
596 due_at: ts("2026-01-02T00:00:00Z")?,
597 };
598
599 let encoded = serde_json::to_value(&candidate)?;
600
601 assert_eq!(
602 encoded,
603 serde_json::json!({
604 "keepsake_id": "00000000-0000-0000-0000-000000000000",
605 "relation_id": "00000000-0000-0000-0000-000000000000",
606 "subject_kind": "account",
607 "subject_id": "acct_123",
608 "due_at": "2026-01-02T00:00:00Z"
609 })
610 );
611 assert_eq!(
612 serde_json::from_value::<TimedExpiryCandidate>(encoded)?,
613 candidate
614 );
615 Ok(())
616 }
617
618 #[test]
619 fn fulfilled_expiry_candidate_serializes_with_stable_field_names() -> Result<(), TestError> {
620 let candidate = FulfilledExpiryCandidate {
621 keepsake_id: Uuid::nil(),
622 relation_id: Uuid::nil(),
623 subject_kind: "account".to_owned(),
624 subject_id: "acct_123".to_owned(),
625 expiry_policy: keepsake::ExpiryPolicy::WhenFulfilled {
626 policy: keepsake::FulfillmentPolicy::CounterAtLeast {
627 key: "steps".to_owned(),
628 threshold: 3,
629 },
630 },
631 };
632
633 let encoded = serde_json::to_value(&candidate)?;
634
635 assert_eq!(
636 encoded,
637 serde_json::json!({
638 "keepsake_id": "00000000-0000-0000-0000-000000000000",
639 "relation_id": "00000000-0000-0000-0000-000000000000",
640 "subject_kind": "account",
641 "subject_id": "acct_123",
642 "expiry_policy": {
643 "type": "when_fulfilled",
644 "policy": {
645 "type": "counter_at_least",
646 "key": "steps",
647 "threshold": 3
648 }
649 }
650 })
651 );
652 assert_eq!(
653 serde_json::from_value::<FulfilledExpiryCandidate>(encoded)?,
654 candidate
655 );
656 Ok(())
657 }
658
659 #[test]
660 fn parse_state_rejects_unknown_values() {
661 let error = parse_state("archived".to_owned())
662 .map(|_| ())
663 .map_err(|error| error.to_string());
664
665 assert_eq!(error, Err("unknown lifecycle state archived".to_owned()));
666 }
667}