rainier_session/
database.rs1use chrono::{DateTime, Duration, Utc};
4use rainier_database::{Criteria, Database, EntityRepository, Migrator, Model, Repository};
5use rainier_orm::Entity;
6use rainier_support::{BoxFuture, Error, Result};
7
8use crate::session::SessionData;
9use crate::store::SessionStore;
10
11#[derive(Entity, Clone, Debug, PartialEq)]
13#[orm(table = "rainier_sessions")]
14pub struct SessionRow {
15 #[orm(pk)]
17 pub id: String,
18
19 pub payload: String,
21
22 #[orm(index)]
24 pub expires_at: DateTime<Utc>,
25
26 pub updated_at: DateTime<Utc>,
28}
29
30impl Model for SessionRow {}
31
32pub struct DatabaseSessionStore {
37 rows: EntityRepository<SessionRow>,
38 lifetime: Duration,
39}
40
41impl DatabaseSessionStore {
42 pub fn new(db: Database) -> Self {
44 Self { rows: EntityRepository::new(db), lifetime: Duration::hours(2) }
45 }
46
47 pub fn with_lifetime(mut self, lifetime: Duration) -> Self {
49 self.lifetime = lifetime;
50 self
51 }
52
53 pub fn migrations() -> Migrator {
63 Migrator::new().create_table::<SessionRow>("rainier_session_0001_sessions")
66 }
67
68 pub async fn prune(&self) -> Result<u64> {
70 self.rows.delete_matching(Criteria::new().where_lte("expires_at", Utc::now())).await
71 }
72}
73
74impl SessionStore for DatabaseSessionStore {
75 fn name(&self) -> &str {
76 "database"
77 }
78
79 fn read<'a>(&'a self, id: &'a str) -> BoxFuture<'a, Result<Option<SessionData>>> {
80 Box::pin(async move {
81 let Some(row) = self.rows.find(id.into()).await? else {
82 return Ok(None);
83 };
84
85 if row.expires_at <= Utc::now() {
86 self.rows.delete(id.into()).await?;
89 return Ok(None);
90 }
91
92 match serde_json::from_str(&row.payload) {
93 Ok(data) => Ok(Some(data)),
94 Err(e) => {
95 tracing::warn!(error = %e, "discarding an unreadable session row");
100 self.rows.delete(id.into()).await?;
101 Ok(None)
102 }
103 }
104 })
105 }
106
107 fn write<'a>(&'a self, id: &'a str, data: &'a SessionData) -> BoxFuture<'a, Result<()>> {
108 Box::pin(async move {
109 let now = Utc::now();
110 let row = SessionRow {
111 id: id.to_string(),
112 payload: serde_json::to_string(data)
113 .map_err(|e| Error::internal(format!("the session will not serialise: {e}")))?,
114 expires_at: now + self.lifetime,
115 updated_at: now,
116 };
117
118 self.rows.upsert(&row, &["id"], &["payload", "expires_at", "updated_at"]).await?;
122 Ok(())
123 })
124 }
125
126 fn destroy<'a>(&'a self, id: &'a str) -> BoxFuture<'a, Result<()>> {
127 Box::pin(async move {
128 self.rows.delete(id.into()).await?;
129 Ok(())
130 })
131 }
132
133 fn gc(&self) -> BoxFuture<'_, Result<u64>> {
134 Box::pin(async move { self.prune().await })
135 }
136}
137
138impl std::fmt::Debug for DatabaseSessionStore {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.debug_struct("DatabaseSessionStore").field("lifetime", &self.lifetime).finish()
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use rainier_database::testing::{fake_database, MemoryConnection};
148 use rainier_database::Dialect;
149
150 #[test]
151 fn the_migration_names_the_table() {
152 let migrator = DatabaseSessionStore::migrations();
153 assert_eq!(migrator.names(), vec!["rainier_session_0001_sessions"]);
154
155 let ddl = rainier_database::schema::schema_ddl::<SessionRow>(Dialect::Sqlite).join("\n");
156 assert!(ddl.contains("rainier_sessions"), "{ddl}");
157 assert!(ddl.contains("expires_at"), "{ddl}");
158 }
159
160 #[tokio::test]
161 async fn writing_upserts_rather_than_inserting() {
162 let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
163 let store = DatabaseSessionStore::new(db);
164
165 store.write("abc", &SessionData::default()).await.unwrap();
166
167 let sql = connection.last_statement().unwrap().to_uppercase();
168 assert!(
169 sql.contains("CONFLICT") || sql.contains("DUPLICATE") || sql.contains("MERGE"),
170 "two concurrent requests for one session must not race: {sql}"
171 );
172 }
173
174 #[tokio::test]
175 async fn an_unknown_session_reads_as_none() {
176 let (db, _) = fake_database(MemoryConnection::new(Dialect::Sqlite));
177 let store = DatabaseSessionStore::new(db);
178
179 assert!(store.read("nope").await.unwrap().is_none());
180 }
181
182 #[tokio::test]
183 async fn pruning_targets_expired_rows() {
184 let (db, connection) = fake_database(MemoryConnection::new(Dialect::Sqlite));
185 let store = DatabaseSessionStore::new(db);
186
187 store.prune().await.unwrap();
188
189 let sql = connection.last_statement().unwrap();
190 assert!(sql.to_uppercase().starts_with("DELETE"), "{sql}");
191 assert!(sql.contains("expires_at"), "{sql}");
192 }
193
194 #[test]
195 fn the_lifetime_is_configurable() {
196 let (db, _) = fake_database(MemoryConnection::new(Dialect::Sqlite));
197 let store = DatabaseSessionStore::new(db).with_lifetime(Duration::days(14));
198
199 assert_eq!(store.lifetime, Duration::days(14));
200 }
201}