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