Skip to main content

keepsake_sqlx/
repository.rs

1//! `SQLx` repository implementation.
2
3use 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
74/// Result alias for SQL repository operations.
75pub type RepositoryResult<T> = core::result::Result<T, RepositoryError>;
76
77/// SQL repository errors.
78#[non_exhaustive]
79#[derive(Debug, thiserror::Error)]
80pub enum RepositoryError {
81    /// `SQLx` returned an error.
82    #[error(transparent)]
83    Sqlx(#[from] sqlx::Error),
84
85    /// Migration failed.
86    #[cfg(feature = "migrations")]
87    #[error(transparent)]
88    Migration(#[from] sqlx::migrate::MigrateError),
89
90    /// JSON policy could not be encoded or decoded.
91    #[error(transparent)]
92    Json(#[from] serde_json::Error),
93
94    /// A Keepsake core model could not be built.
95    #[error(transparent)]
96    Keepsake(#[from] keepsake::KeepsakeError),
97
98    /// Existing schema metadata belongs to a different backend.
99    #[error("schema backend mismatch: expected {expected}, found {actual}")]
100    BackendMismatch {
101        /// Backend expected by this repository.
102        expected: &'static str,
103        /// Backend found in schema metadata.
104        actual: String,
105    },
106
107    /// A command tried to mutate a disabled relation.
108    #[error("relation {relation_id} is disabled")]
109    RelationDisabled {
110        /// Disabled relation id.
111        relation_id: Uuid,
112    },
113
114    /// A typed relation spec conflicts with an existing natural-key row.
115    #[error(
116        "relation spec {kind}/{name} expected id {expected_relation_id}, but stored relation uses {stored_relation_id}"
117    )]
118    RelationSpecIdMismatch {
119        /// Relation kind.
120        kind: String,
121        /// Relation name.
122        name: String,
123        /// Relation id declared by the typed spec.
124        expected_relation_id: Uuid,
125        /// Existing stored relation id for the same natural key.
126        stored_relation_id: Uuid,
127    },
128
129    /// A keepsake row referenced a missing relation definition.
130    #[error("relation definition {relation_id} was not found")]
131    RelationDefinitionMissing {
132        /// Missing relation id.
133        relation_id: Uuid,
134    },
135
136    /// A batch or scan limit was outside the accepted range.
137    #[error("limit {limit} is outside the accepted range 1..={max}")]
138    InvalidLimit {
139        /// Provided limit.
140        limit: i64,
141        /// Maximum accepted limit.
142        max: i64,
143    },
144
145    /// A row contained an unknown lifecycle state.
146    #[error("unknown lifecycle state {state}")]
147    InvalidLifecycleState {
148        /// Stored state value.
149        state: String,
150    },
151
152    /// A stored audit event carried an unknown event type label.
153    #[error("unknown audit event type {event_type}")]
154    InvalidAuditEventType {
155        /// Stored event type label.
156        event_type: String,
157    },
158}
159
160/// `SQLx`-backed keepsake repository.
161#[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/// Postgres-backed keepsake repository.
187#[cfg(feature = "postgres")]
188pub type PostgresKeepsakeRepository<C = NoopRelationCache> =
189    SqlxKeepsakeRepository<PostgresBackend, C>;
190
191/// Default Postgres-backed keepsake repository.
192#[cfg(feature = "postgres")]
193pub type KeepsakeRepository<C = NoopRelationCache> = PostgresKeepsakeRepository<C>;
194
195/// SQLite-backed keepsake repository.
196#[cfg(feature = "sqlite")]
197pub type SqliteKeepsakeRepository<C = NoopRelationCache> = SqlxKeepsakeRepository<SqliteBackend, C>;
198
199/// MySQL-backed keepsake repository.
200#[cfg(feature = "mysql")]
201pub type MySqlKeepsakeRepository<C = NoopRelationCache> = SqlxKeepsakeRepository<MySqlBackend, C>;
202
203#[cfg(feature = "postgres")]
204impl PostgresKeepsakeRepository<NoopRelationCache> {
205    /// Creates a repository from a Postgres pool.
206    #[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    /// Creates a repository from a `SQLite` pool.
219    #[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    /// Creates a repository from a `MySQL` pool.
232    #[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    /// Creates a timestamp-scoped repository view.
248    ///
249    /// Use this at request or job boundaries to keep one explicit clock read while
250    /// avoiding repeated timestamp plumbing through related repository calls.
251    pub const fn at(&self, at: DateTime<Utc>) -> TimedSqlxKeepsakeRepository<'_, B, C> {
252        TimedSqlxKeepsakeRepository {
253            repository: self,
254            at,
255        }
256    }
257
258    /// Enables relation definition caching for read helper methods.
259    #[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    /// Enables local in-process relation definition caching for read helper methods.
272    ///
273    /// This cache is per-process and has no cross-pod invalidation. Keep the
274    /// default [`NoopRelationCache`] when relation definitions change frequently
275    /// or when a multi-pod deployment needs invalidation guarantees.
276    #[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    /// Runs embedded migrations.
292    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    /// Runs embedded `SQLite` migrations.
305    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    /// Runs embedded `MySQL` migrations.
318    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}