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;
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
69/// Result alias for SQL repository operations.
70pub type RepositoryResult<T> = core::result::Result<T, RepositoryError>;
71
72/// SQL repository errors.
73#[non_exhaustive]
74#[derive(Debug, thiserror::Error)]
75pub enum RepositoryError {
76    /// `SQLx` returned an error.
77    #[error(transparent)]
78    Sqlx(#[from] sqlx::Error),
79
80    /// Migration failed.
81    #[cfg(feature = "migrations")]
82    #[error(transparent)]
83    Migration(#[from] sqlx::migrate::MigrateError),
84
85    /// JSON policy could not be encoded or decoded.
86    #[error(transparent)]
87    Json(#[from] serde_json::Error),
88
89    /// A Keepsake core model could not be built.
90    #[error(transparent)]
91    Keepsake(#[from] keepsake::KeepsakeError),
92
93    /// Existing schema metadata belongs to a different backend.
94    #[error("schema backend mismatch: expected {expected}, found {actual}")]
95    BackendMismatch {
96        /// Backend expected by this repository.
97        expected: &'static str,
98        /// Backend found in schema metadata.
99        actual: String,
100    },
101
102    /// A command tried to mutate a disabled relation.
103    #[error("relation {relation_id} is disabled")]
104    RelationDisabled {
105        /// Disabled relation id.
106        relation_id: Uuid,
107    },
108
109    /// A typed relation spec conflicts with an existing natural-key row.
110    #[error(
111        "relation spec {kind}/{name} expected id {expected_relation_id}, but stored relation uses {stored_relation_id}"
112    )]
113    RelationSpecIdMismatch {
114        /// Relation kind.
115        kind: String,
116        /// Relation name.
117        name: String,
118        /// Relation id declared by the typed spec.
119        expected_relation_id: Uuid,
120        /// Existing stored relation id for the same natural key.
121        stored_relation_id: Uuid,
122    },
123
124    /// A keepsake row referenced a missing relation definition.
125    #[error("relation definition {relation_id} was not found")]
126    RelationDefinitionMissing {
127        /// Missing relation id.
128        relation_id: Uuid,
129    },
130
131    /// A batch or scan limit was outside the accepted range.
132    #[error("limit {limit} is outside the accepted range 1..={max}")]
133    InvalidLimit {
134        /// Provided limit.
135        limit: i64,
136        /// Maximum accepted limit.
137        max: i64,
138    },
139
140    /// A row contained an unknown lifecycle state.
141    #[error("unknown lifecycle state {state}")]
142    InvalidLifecycleState {
143        /// Stored state value.
144        state: String,
145    },
146}
147
148/// `SQLx`-backed keepsake repository.
149#[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/// Postgres-backed keepsake repository.
175#[cfg(feature = "postgres")]
176pub type PostgresKeepsakeRepository<C = NoopRelationCache> =
177    SqlxKeepsakeRepository<PostgresBackend, C>;
178
179/// Default Postgres-backed keepsake repository.
180#[cfg(feature = "postgres")]
181pub type KeepsakeRepository<C = NoopRelationCache> = PostgresKeepsakeRepository<C>;
182
183/// SQLite-backed keepsake repository.
184#[cfg(feature = "sqlite")]
185pub type SqliteKeepsakeRepository<C = NoopRelationCache> = SqlxKeepsakeRepository<SqliteBackend, C>;
186
187/// MySQL-backed keepsake repository.
188#[cfg(feature = "mysql")]
189pub type MySqlKeepsakeRepository<C = NoopRelationCache> = SqlxKeepsakeRepository<MySqlBackend, C>;
190
191#[cfg(feature = "postgres")]
192impl PostgresKeepsakeRepository<NoopRelationCache> {
193    /// Creates a repository from a Postgres pool.
194    #[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    /// Creates a repository from a `SQLite` pool.
207    #[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    /// Creates a repository from a `MySQL` pool.
220    #[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    /// Creates a timestamp-scoped repository view.
236    ///
237    /// Use this at request or job boundaries to keep one explicit clock read while
238    /// avoiding repeated timestamp plumbing through related repository calls.
239    pub const fn at(&self, at: DateTime<Utc>) -> TimedSqlxKeepsakeRepository<'_, B, C> {
240        TimedSqlxKeepsakeRepository {
241            repository: self,
242            at,
243        }
244    }
245
246    /// Enables relation definition caching for read helper methods.
247    #[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    /// Enables local in-process relation definition caching for read helper methods.
260    ///
261    /// This cache is per-process and has no cross-pod invalidation. Keep the
262    /// default [`NoopRelationCache`] when relation definitions change frequently
263    /// or when a multi-pod deployment needs invalidation guarantees.
264    #[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    /// Runs embedded migrations.
280    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    /// Runs embedded `SQLite` migrations.
293    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    /// Runs embedded `MySQL` migrations.
306    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}