1use chrono::{DateTime, Utc};
4use r2d2::Pool;
5use r2d2_sqlite::SqliteConnectionManager;
6use rusqlite::OptionalExtension as _;
7use stateset_core::{CommerceError, Result};
8
9use crate::{HttpIdempotencyRecord, HttpIdempotencyRepository};
10
11#[derive(Debug)]
13pub struct SqliteHttpIdempotencyRepository {
14 pool: Pool<SqliteConnectionManager>,
15}
16
17impl SqliteHttpIdempotencyRepository {
18 #[must_use]
19 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
20 Self { pool }
21 }
22
23 fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
24 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
25 }
26}
27
28fn map_err(e: rusqlite::Error) -> CommerceError {
29 CommerceError::DatabaseError(e.to_string())
30}
31
32impl HttpIdempotencyRepository for SqliteHttpIdempotencyRepository {
33 fn get(
34 &self,
35 tenant: &str,
36 key: &str,
37 expired_before: DateTime<Utc>,
38 ) -> Result<Option<HttpIdempotencyRecord>> {
39 let conn = self.conn()?;
40 conn.execute(
46 "DELETE FROM http_idempotency_keys
47 WHERE tenant = ?1 AND idempotency_key = ?2 AND created_at <= ?3",
48 rusqlite::params![tenant, key, expired_before.timestamp_millis()],
49 )
50 .map_err(map_err)?;
51
52 conn.query_row(
53 "SELECT request_fingerprint, response_status, content_type, response_body, created_at
54 FROM http_idempotency_keys
55 WHERE tenant = ?1 AND idempotency_key = ?2",
56 rusqlite::params![tenant, key],
57 |row| {
58 Ok((
59 row.get::<_, String>(0)?,
60 row.get::<_, u16>(1)?,
61 row.get::<_, Option<String>>(2)?,
62 row.get::<_, Vec<u8>>(3)?,
63 row.get::<_, i64>(4)?,
64 ))
65 },
66 )
67 .optional()
68 .map_err(map_err)?
69 .map(|(request_fingerprint, response_status, content_type, response_body, created_ms)| {
70 let created_at =
71 DateTime::<Utc>::from_timestamp_millis(created_ms).ok_or_else(|| {
72 CommerceError::DatabaseError(format!(
73 "http_idempotency_keys.created_at out of range: {created_ms}"
74 ))
75 })?;
76 Ok(HttpIdempotencyRecord {
77 tenant: tenant.to_string(),
78 idempotency_key: key.to_string(),
79 request_fingerprint,
80 response_status,
81 content_type,
82 response_body,
83 created_at,
84 })
85 })
86 .transpose()
87 }
88
89 fn put(&self, record: &HttpIdempotencyRecord) -> Result<bool> {
90 let conn = self.conn()?;
91 let inserted = conn
92 .execute(
93 "INSERT INTO http_idempotency_keys
94 (tenant, idempotency_key, request_fingerprint, response_status,
95 content_type, response_body, created_at)
96 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
97 ON CONFLICT(tenant, idempotency_key) DO NOTHING",
98 rusqlite::params![
99 record.tenant,
100 record.idempotency_key,
101 record.request_fingerprint,
102 record.response_status,
103 record.content_type,
104 record.response_body,
105 record.created_at.timestamp_millis(),
106 ],
107 )
108 .map_err(map_err)?;
109 Ok(inserted > 0)
110 }
111
112 fn purge_expired(&self, expired_before: DateTime<Utc>) -> Result<u64> {
113 let conn = self.conn()?;
114 let deleted = conn
115 .execute(
116 "DELETE FROM http_idempotency_keys WHERE created_at < ?1",
117 rusqlite::params![expired_before.timestamp_millis()],
118 )
119 .map_err(map_err)?;
120 Ok(deleted as u64)
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::{DatabaseConfig, SqliteDatabase};
128 use chrono::Duration;
129
130 fn repo() -> SqliteHttpIdempotencyRepository {
131 let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
132 db.http_idempotency()
133 }
134
135 fn record(key: &str, created_at: DateTime<Utc>) -> HttpIdempotencyRecord {
136 HttpIdempotencyRecord {
137 tenant: "tenant-a".into(),
138 idempotency_key: key.into(),
139 request_fingerprint: "f".repeat(64),
140 response_status: 201,
141 content_type: Some("application/json".into()),
142 response_body: b"{\"id\":\"order-1\"}".to_vec(),
143 created_at,
144 }
145 }
146
147 #[test]
148 fn put_then_get_round_trips() {
149 let repo = repo();
150 let now = Utc::now();
151 let rec = record("k1", now);
152 assert!(repo.put(&rec).unwrap());
153
154 let cutoff = now - Duration::hours(1);
155 let loaded = repo.get("tenant-a", "k1", cutoff).unwrap().expect("record");
156 assert_eq!(loaded.request_fingerprint, rec.request_fingerprint);
157 assert_eq!(loaded.response_status, 201);
158 assert_eq!(loaded.content_type.as_deref(), Some("application/json"));
159 assert_eq!(loaded.response_body, rec.response_body);
160 assert_eq!(loaded.created_at.timestamp_millis(), now.timestamp_millis());
162 assert!(repo.get("tenant-b", "k1", cutoff).unwrap().is_none());
164 assert!(repo.get("tenant-a", "other", cutoff).unwrap().is_none());
165 }
166
167 #[test]
168 fn put_is_first_write_wins() {
169 let repo = repo();
170 let now = Utc::now();
171 assert!(repo.put(&record("k1", now)).unwrap());
172
173 let mut second = record("k1", now);
174 second.response_body = b"different".to_vec();
175 assert!(!repo.put(&second).unwrap());
177 let loaded = repo.get("tenant-a", "k1", now - Duration::hours(1)).unwrap().expect("record");
178 assert_eq!(loaded.response_body, b"{\"id\":\"order-1\"}");
179 }
180
181 #[test]
182 fn get_lazily_deletes_expired_rows() {
183 let repo = repo();
184 let created = Utc::now() - Duration::hours(25);
185 assert!(repo.put(&record("k1", created)).unwrap());
186
187 let cutoff = Utc::now() - Duration::hours(24);
189 assert!(repo.get("tenant-a", "k1", cutoff).unwrap().is_none());
190 assert!(repo.get("tenant-a", "k1", DateTime::<Utc>::MIN_UTC).unwrap().is_none());
192 assert!(repo.put(&record("k1", Utc::now())).unwrap());
194 }
195
196 #[test]
197 fn purge_expired_sweeps_only_old_rows() {
198 let repo = repo();
199 let now = Utc::now();
200 assert!(repo.put(&record("old-1", now - Duration::hours(30))).unwrap());
201 assert!(repo.put(&record("old-2", now - Duration::hours(25))).unwrap());
202 assert!(repo.put(&record("fresh", now)).unwrap());
203
204 let purged = repo.purge_expired(now - Duration::hours(24)).unwrap();
205 assert_eq!(purged, 2);
206 assert!(repo.get("tenant-a", "fresh", now - Duration::hours(24)).unwrap().is_some());
207 }
208 #[test]
209 fn entry_created_exactly_at_cutoff_is_expired() {
210 let repo = repo();
216 let created = Utc::now();
217 assert!(repo.put(&record("boundary", created)).unwrap());
218
219 let hit = repo.get("tenant-a", "boundary", created).unwrap();
220 assert!(hit.is_none(), "created_at == cutoff must be expired: {hit:?}");
221 }
222}