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, AuditOutboxCursor, AuditOutboxRecord,
53    FulfilledExpiryCandidate, MembershipCursor, 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        schema::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        schema::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        schema::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(feature = "migrations")]
338mod schema;
339#[cfg(all(test, feature = "postgres"))]
340mod tests;