Skip to main content

arc_es_sqlite/
read_model_store.rs

1//! # SQLite Read Model Store
2//!
3//! Production-grade implementation of [`ReadModelStore`] backed by an
4//! `r2d2`-pooled SQLite connection. Each projection table follows the
5//! framework's standard projection shape: `(id TEXT PK, version BIGINT,
6//! data TEXT)`. The `data` column holds the full row as JSON, so this store
7//! can serve any projector without compile-time knowledge of the
8//! domain-specific columns.
9//!
10//! ## Idempotency
11//!
12//! [`upsert`](ReadModelStore::upsert) translates to
13//!
14//! ```sql
15//! INSERT INTO {table} (id, version, data) VALUES (?, ?, ?)
16//! ON CONFLICT(id) DO UPDATE
17//!    SET version = excluded.version, data = excluded.data
18//!  WHERE {table}.version < excluded.version
19//! ```
20//!
21//! That gate makes replay-from-zero deterministic and tolerates at-least-once
22//! delivery: applying an older event twice, or out-of-order, never regresses
23//! state.
24//!
25//! ## Queries
26//!
27//! [`find_by`](ReadModelStore::find_by) uses `json_extract(data, '$.{field}')`
28//! to reach into the blob. Columns commonly queried (e.g. `email`) get
29//! expression indexes in the migration that creates the table.
30//!
31//! ## Boundaries
32//!
33//! The store does **not** create tables — DDL lives in `migrations/`. That
34//! keeps the production write path away from connection-time mutations and
35//! lets `diesel migration run` control schema evolution.
36
37use arc_core::read_model_store::{ReadModelError, ReadModelResult, ReadModelStore, Row, Upsert};
38use async_trait::async_trait;
39use diesel::prelude::*;
40use diesel::r2d2::{self, ConnectionManager};
41use diesel::sql_types::{BigInt, Nullable, Text};
42use diesel::sqlite::SqliteConnection;
43use std::sync::Arc;
44
45type Pool = r2d2::Pool<ConnectionManager<SqliteConnection>>;
46
47/// SQLite implementation of [`ReadModelStore`].
48#[derive(Clone)]
49pub struct SqliteReadModelStore {
50    pool: Arc<Pool>,
51}
52
53impl SqliteReadModelStore {
54    /// Build a new store from a database URL. Creates a small r2d2 pool.
55    pub async fn new(database_url: &str) -> ReadModelResult<Self> {
56        let manager = ConnectionManager::<SqliteConnection>::new(database_url);
57        let pool = Pool::builder().max_size(10).build(manager).map_err(|e| {
58            ReadModelError::other(format!("Failed to build read-model pool: {}", e))
59        })?;
60        Ok(SqliteReadModelStore {
61            pool: Arc::new(pool),
62        })
63    }
64
65    /// Build a store from an existing pool. Used in tests so we can share a
66    /// single in-memory database with the event store.
67    pub fn with_pool(pool: Pool) -> Self {
68        Self {
69            pool: Arc::new(pool),
70        }
71    }
72}
73
74#[derive(QueryableByName, Debug)]
75struct DataRow {
76    #[diesel(sql_type = Text)]
77    data: String,
78}
79
80/// Validate a table or column name against an allow-list of characters before
81/// splicing it into a SQL string. Diesel's `sql_query` does not bind
82/// identifiers, only values. This guards against caller-supplied identifiers
83/// reaching SQL with anything other than `[A-Za-z0-9_]` — same posture as
84/// every other SQL library that accepts dynamic table names.
85fn check_ident(label: &str, ident: &str) -> ReadModelResult<()> {
86    if ident.is_empty() {
87        return Err(ReadModelError::other(format!("{label} cannot be empty")));
88    }
89    if !ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
90        return Err(ReadModelError::other(format!(
91            "invalid {label} '{ident}': only [A-Za-z0-9_] permitted"
92        )));
93    }
94    Ok(())
95}
96
97fn extract_version(row: &Row) -> ReadModelResult<i64> {
98    row.get("version").and_then(|v| v.as_i64()).ok_or_else(|| {
99        ReadModelError::write_failed(
100            "Upsert.row missing required i64 field 'version' for version-gated upsert",
101        )
102    })
103}
104
105fn parse_data_row(raw: &str) -> ReadModelResult<Row> {
106    serde_json::from_str(raw).map_err(|e| {
107        ReadModelError::query_failed(format!("Failed to parse projection row JSON: {e}"))
108    })
109}
110
111#[async_trait]
112impl ReadModelStore for SqliteReadModelStore {
113    async fn upsert(&self, op: Upsert) -> ReadModelResult<()> {
114        check_ident("table name", &op.table)?;
115        let version = extract_version(&op.row)?;
116        let data = serde_json::to_string(&op.row)
117            .map_err(|e| ReadModelError::write_failed(format!("Failed to serialize row: {e}")))?;
118        let pool = self.pool.clone();
119        let table = op.table.clone();
120        let key = op.key.clone();
121
122        tokio::task::spawn_blocking(move || -> ReadModelResult<()> {
123            let mut conn = pool.get().map_err(|e| {
124                ReadModelError::write_failed(format!("Failed to get connection: {e}"))
125            })?;
126
127            let sql = format!(
128                "INSERT INTO {table} (id, version, data) VALUES (?, ?, ?) \
129                 ON CONFLICT(id) DO UPDATE SET version = excluded.version, data = excluded.data \
130                 WHERE {table}.version < excluded.version"
131            );
132
133            diesel::sql_query(sql)
134                .bind::<Text, _>(key)
135                .bind::<BigInt, _>(version)
136                .bind::<Text, _>(data)
137                .execute(&mut *conn)
138                .map_err(|e| ReadModelError::write_failed(e.to_string()))?;
139            Ok(())
140        })
141        .await
142        .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))?
143    }
144
145    async fn delete(&self, table: &str, key: &str) -> ReadModelResult<()> {
146        check_ident("table name", table)?;
147        let pool = self.pool.clone();
148        let table = table.to_string();
149        let key = key.to_string();
150
151        tokio::task::spawn_blocking(move || -> ReadModelResult<()> {
152            let mut conn = pool.get().map_err(|e| {
153                ReadModelError::write_failed(format!("Failed to get connection: {e}"))
154            })?;
155            diesel::sql_query(format!("DELETE FROM {table} WHERE id = ?"))
156                .bind::<Text, _>(key)
157                .execute(&mut *conn)
158                .map_err(|e| ReadModelError::write_failed(e.to_string()))?;
159            Ok(())
160        })
161        .await
162        .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))?
163    }
164
165    async fn get(&self, table: &str, key: &str) -> ReadModelResult<Option<Row>> {
166        check_ident("table name", table)?;
167        let pool = self.pool.clone();
168        let table = table.to_string();
169        let key = key.to_string();
170
171        let raw = tokio::task::spawn_blocking(move || -> ReadModelResult<Option<String>> {
172            let mut conn = pool.get().map_err(|e| {
173                ReadModelError::query_failed(format!("Failed to get connection: {e}"))
174            })?;
175            let rows: Vec<DataRow> =
176                diesel::sql_query(format!("SELECT data FROM {table} WHERE id = ? LIMIT 1"))
177                    .bind::<Text, _>(key)
178                    .load(&mut *conn)
179                    .map_err(|e| ReadModelError::query_failed(e.to_string()))?;
180            Ok(rows.into_iter().next().map(|r| r.data))
181        })
182        .await
183        .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))??;
184
185        match raw {
186            Some(s) => Ok(Some(parse_data_row(&s)?)),
187            None => Ok(None),
188        }
189    }
190
191    async fn find_by(
192        &self,
193        table: &str,
194        field: &str,
195        value: &serde_json::Value,
196    ) -> ReadModelResult<Vec<Row>> {
197        check_ident("table name", table)?;
198        check_ident("field name", field)?;
199        let pool = self.pool.clone();
200        let table = table.to_string();
201        let path = format!("$.{field}");
202
203        // We only support primitive comparisons here. Anything else is a
204        // caller bug, not a runtime exception worth tolerating.
205        let bind_text: Option<String> = match value {
206            serde_json::Value::Null => None,
207            serde_json::Value::String(s) => Some(s.clone()),
208            serde_json::Value::Number(n) => Some(n.to_string()),
209            serde_json::Value::Bool(b) => Some(if *b { "1".into() } else { "0".into() }),
210            other => {
211                return Err(ReadModelError::query_failed(format!(
212                    "find_by only accepts primitive values, got: {other}"
213                )))
214            }
215        };
216
217        let rows = tokio::task::spawn_blocking(move || -> ReadModelResult<Vec<String>> {
218            let mut conn = pool.get().map_err(|e| {
219                ReadModelError::query_failed(format!("Failed to get connection: {e}"))
220            })?;
221            let sql = format!("SELECT data FROM {table} WHERE json_extract(data, ?) IS ?");
222            let rows: Vec<DataRow> = diesel::sql_query(sql)
223                .bind::<Text, _>(path)
224                .bind::<Nullable<Text>, _>(bind_text)
225                .load(&mut *conn)
226                .map_err(|e| ReadModelError::query_failed(e.to_string()))?;
227            Ok(rows.into_iter().map(|r| r.data).collect())
228        })
229        .await
230        .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))??;
231
232        rows.iter().map(|r| parse_data_row(r)).collect()
233    }
234
235    async fn list(&self, table: &str) -> ReadModelResult<Vec<Row>> {
236        check_ident("table name", table)?;
237        let pool = self.pool.clone();
238        let table = table.to_string();
239
240        let rows = tokio::task::spawn_blocking(move || -> ReadModelResult<Vec<String>> {
241            let mut conn = pool.get().map_err(|e| {
242                ReadModelError::query_failed(format!("Failed to get connection: {e}"))
243            })?;
244            let rows: Vec<DataRow> = diesel::sql_query(format!("SELECT data FROM {table}"))
245                .load(&mut *conn)
246                .map_err(|e| ReadModelError::query_failed(e.to_string()))?;
247            Ok(rows.into_iter().map(|r| r.data).collect())
248        })
249        .await
250        .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))??;
251
252        rows.iter().map(|r| parse_data_row(r)).collect()
253    }
254
255    async fn truncate(&self, table: &str) -> ReadModelResult<()> {
256        check_ident("table name", table)?;
257        let pool = self.pool.clone();
258        let table = table.to_string();
259
260        tokio::task::spawn_blocking(move || -> ReadModelResult<()> {
261            let mut conn = pool.get().map_err(|e| {
262                ReadModelError::schema_failed(format!("Failed to get connection: {e}"))
263            })?;
264            diesel::sql_query(format!("DELETE FROM {table}"))
265                .execute(&mut *conn)
266                .map_err(|e| ReadModelError::schema_failed(e.to_string()))?;
267            Ok(())
268        })
269        .await
270        .map_err(|e| ReadModelError::other(format!("Task join error: {e}")))?
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use serde_json::json;
278
279    async fn setup() -> SqliteReadModelStore {
280        let manager = ConnectionManager::<SqliteConnection>::new(":memory:");
281        let pool = Pool::builder().max_size(1).build(manager).expect("pool");
282        let mut conn = pool.get().expect("conn");
283        crate::test_support::migrate(&mut conn);
284        drop(conn);
285        SqliteReadModelStore::with_pool(pool)
286    }
287
288    fn user_row(id: &str, name: &str, email: &str, version: i64) -> Row {
289        json!({
290            "id": id,
291            "name": name,
292            "email": email,
293            "password_hash": "$argon2id$v=19$m=19456,t=2,p=1$x$y",
294            "version": version,
295        })
296    }
297
298    #[tokio::test]
299    async fn test_upsert_inserts_then_reads() {
300        let store = setup().await;
301        store
302            .upsert(Upsert::new(
303                "users_view",
304                "u1",
305                user_row("u1", "Alice", "a@b.c", 1),
306            ))
307            .await
308            .unwrap();
309
310        let got = store.get("users_view", "u1").await.unwrap().unwrap();
311        assert_eq!(got["name"], "Alice");
312        assert_eq!(got["version"], 1);
313    }
314
315    #[tokio::test]
316    async fn test_upsert_advances_on_higher_version() {
317        let store = setup().await;
318        store
319            .upsert(Upsert::new(
320                "users_view",
321                "u1",
322                user_row("u1", "Alice", "a@b.c", 1),
323            ))
324            .await
325            .unwrap();
326        store
327            .upsert(Upsert::new(
328                "users_view",
329                "u1",
330                user_row("u1", "Alice2", "a@b.c", 2),
331            ))
332            .await
333            .unwrap();
334
335        let got = store.get("users_view", "u1").await.unwrap().unwrap();
336        assert_eq!(got["name"], "Alice2");
337        assert_eq!(got["version"], 2);
338    }
339
340    #[tokio::test]
341    async fn test_upsert_idempotent_on_lower_or_equal_version() {
342        // Replay must not regress newer state.
343        let store = setup().await;
344        store
345            .upsert(Upsert::new(
346                "users_view",
347                "u1",
348                user_row("u1", "Alice2", "a@b.c", 2),
349            ))
350            .await
351            .unwrap();
352
353        // Same version: SQLite ON CONFLICT WHERE clause filters this out.
354        store
355            .upsert(Upsert::new(
356                "users_view",
357                "u1",
358                user_row("u1", "Should not stick", "a@b.c", 2),
359            ))
360            .await
361            .unwrap();
362        // Lower version: same.
363        store
364            .upsert(Upsert::new(
365                "users_view",
366                "u1",
367                user_row("u1", "Stale", "a@b.c", 1),
368            ))
369            .await
370            .unwrap();
371
372        let got = store.get("users_view", "u1").await.unwrap().unwrap();
373        assert_eq!(got["name"], "Alice2");
374        assert_eq!(got["version"], 2);
375    }
376
377    #[tokio::test]
378    async fn test_find_by_email_uses_index() {
379        let store = setup().await;
380        store
381            .upsert(Upsert::new(
382                "users_view",
383                "u1",
384                user_row("u1", "Alice", "a@b.c", 1),
385            ))
386            .await
387            .unwrap();
388        store
389            .upsert(Upsert::new(
390                "users_view",
391                "u2",
392                user_row("u2", "Bob", "b@b.c", 1),
393            ))
394            .await
395            .unwrap();
396
397        let hits = store
398            .find_by("users_view", "email", &json!("b@b.c"))
399            .await
400            .unwrap();
401        assert_eq!(hits.len(), 1);
402        assert_eq!(hits[0]["id"], "u2");
403    }
404
405    #[tokio::test]
406    async fn test_delete_and_truncate() {
407        let store = setup().await;
408        store
409            .upsert(Upsert::new(
410                "users_view",
411                "u1",
412                user_row("u1", "Alice", "a@b.c", 1),
413            ))
414            .await
415            .unwrap();
416        store
417            .upsert(Upsert::new(
418                "users_view",
419                "u2",
420                user_row("u2", "Bob", "b@b.c", 1),
421            ))
422            .await
423            .unwrap();
424
425        store.delete("users_view", "u1").await.unwrap();
426        assert!(store.get("users_view", "u1").await.unwrap().is_none());
427        assert_eq!(store.list("users_view").await.unwrap().len(), 1);
428
429        store.truncate("users_view").await.unwrap();
430        assert!(store.list("users_view").await.unwrap().is_empty());
431    }
432
433    #[tokio::test]
434    async fn test_email_unique_constraint_rejects_collision() {
435        // Pinned: the migration's UNIQUE INDEX on json_extract(data,'$.email')
436        // protects login-by-email from ambiguity. Two rows with the same email
437        // must not coexist.
438        let store = setup().await;
439        store
440            .upsert(Upsert::new(
441                "users_view",
442                "u1",
443                user_row("u1", "Alice", "x@y.z", 1),
444            ))
445            .await
446            .unwrap();
447        let err = store
448            .upsert(Upsert::new(
449                "users_view",
450                "u2",
451                user_row("u2", "Bob", "x@y.z", 1),
452            ))
453            .await
454            .unwrap_err();
455        assert!(
456            matches!(err, ReadModelError::WriteFailed { ref message } if message.contains("UNIQUE")),
457            "expected UNIQUE constraint violation, got {err:?}"
458        );
459    }
460
461    #[tokio::test]
462    async fn test_table_name_validation_rejects_injection() {
463        let store = setup().await;
464        let err = store
465            .get("users_view; DROP TABLE users_view", "u1")
466            .await
467            .unwrap_err();
468        assert!(
469            matches!(err, ReadModelError::Other { ref message } if message.contains("table name")),
470            "expected identifier rejection, got {err:?}"
471        );
472    }
473}