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 diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
278    use serde_json::json;
279
280    const MIGRATIONS: EmbeddedMigrations = embed_migrations!("../../migrations");
281
282    async fn setup() -> SqliteReadModelStore {
283        let manager = ConnectionManager::<SqliteConnection>::new(":memory:");
284        let pool = Pool::builder().max_size(1).build(manager).expect("pool");
285        let mut conn = pool.get().expect("conn");
286        conn.run_pending_migrations(MIGRATIONS).expect("migrations");
287        drop(conn);
288        SqliteReadModelStore::with_pool(pool)
289    }
290
291    fn user_row(id: &str, name: &str, email: &str, version: i64) -> Row {
292        json!({
293            "id": id,
294            "name": name,
295            "email": email,
296            "password_hash": "$argon2id$v=19$m=19456,t=2,p=1$x$y",
297            "version": version,
298        })
299    }
300
301    #[tokio::test]
302    async fn test_upsert_inserts_then_reads() {
303        let store = setup().await;
304        store
305            .upsert(Upsert::new(
306                "users_view",
307                "u1",
308                user_row("u1", "Alice", "a@b.c", 1),
309            ))
310            .await
311            .unwrap();
312
313        let got = store.get("users_view", "u1").await.unwrap().unwrap();
314        assert_eq!(got["name"], "Alice");
315        assert_eq!(got["version"], 1);
316    }
317
318    #[tokio::test]
319    async fn test_upsert_advances_on_higher_version() {
320        let store = setup().await;
321        store
322            .upsert(Upsert::new(
323                "users_view",
324                "u1",
325                user_row("u1", "Alice", "a@b.c", 1),
326            ))
327            .await
328            .unwrap();
329        store
330            .upsert(Upsert::new(
331                "users_view",
332                "u1",
333                user_row("u1", "Alice2", "a@b.c", 2),
334            ))
335            .await
336            .unwrap();
337
338        let got = store.get("users_view", "u1").await.unwrap().unwrap();
339        assert_eq!(got["name"], "Alice2");
340        assert_eq!(got["version"], 2);
341    }
342
343    #[tokio::test]
344    async fn test_upsert_idempotent_on_lower_or_equal_version() {
345        // Replay must not regress newer state.
346        let store = setup().await;
347        store
348            .upsert(Upsert::new(
349                "users_view",
350                "u1",
351                user_row("u1", "Alice2", "a@b.c", 2),
352            ))
353            .await
354            .unwrap();
355
356        // Same version: SQLite ON CONFLICT WHERE clause filters this out.
357        store
358            .upsert(Upsert::new(
359                "users_view",
360                "u1",
361                user_row("u1", "Should not stick", "a@b.c", 2),
362            ))
363            .await
364            .unwrap();
365        // Lower version: same.
366        store
367            .upsert(Upsert::new(
368                "users_view",
369                "u1",
370                user_row("u1", "Stale", "a@b.c", 1),
371            ))
372            .await
373            .unwrap();
374
375        let got = store.get("users_view", "u1").await.unwrap().unwrap();
376        assert_eq!(got["name"], "Alice2");
377        assert_eq!(got["version"], 2);
378    }
379
380    #[tokio::test]
381    async fn test_find_by_email_uses_index() {
382        let store = setup().await;
383        store
384            .upsert(Upsert::new(
385                "users_view",
386                "u1",
387                user_row("u1", "Alice", "a@b.c", 1),
388            ))
389            .await
390            .unwrap();
391        store
392            .upsert(Upsert::new(
393                "users_view",
394                "u2",
395                user_row("u2", "Bob", "b@b.c", 1),
396            ))
397            .await
398            .unwrap();
399
400        let hits = store
401            .find_by("users_view", "email", &json!("b@b.c"))
402            .await
403            .unwrap();
404        assert_eq!(hits.len(), 1);
405        assert_eq!(hits[0]["id"], "u2");
406    }
407
408    #[tokio::test]
409    async fn test_delete_and_truncate() {
410        let store = setup().await;
411        store
412            .upsert(Upsert::new(
413                "users_view",
414                "u1",
415                user_row("u1", "Alice", "a@b.c", 1),
416            ))
417            .await
418            .unwrap();
419        store
420            .upsert(Upsert::new(
421                "users_view",
422                "u2",
423                user_row("u2", "Bob", "b@b.c", 1),
424            ))
425            .await
426            .unwrap();
427
428        store.delete("users_view", "u1").await.unwrap();
429        assert!(store.get("users_view", "u1").await.unwrap().is_none());
430        assert_eq!(store.list("users_view").await.unwrap().len(), 1);
431
432        store.truncate("users_view").await.unwrap();
433        assert!(store.list("users_view").await.unwrap().is_empty());
434    }
435
436    #[tokio::test]
437    async fn test_email_unique_constraint_rejects_collision() {
438        // Pinned: the migration's UNIQUE INDEX on json_extract(data,'$.email')
439        // protects login-by-email from ambiguity. Two rows with the same email
440        // must not coexist.
441        let store = setup().await;
442        store
443            .upsert(Upsert::new(
444                "users_view",
445                "u1",
446                user_row("u1", "Alice", "x@y.z", 1),
447            ))
448            .await
449            .unwrap();
450        let err = store
451            .upsert(Upsert::new(
452                "users_view",
453                "u2",
454                user_row("u2", "Bob", "x@y.z", 1),
455            ))
456            .await
457            .unwrap_err();
458        assert!(
459            matches!(err, ReadModelError::WriteFailed { ref message } if message.contains("UNIQUE")),
460            "expected UNIQUE constraint violation, got {err:?}"
461        );
462    }
463
464    #[tokio::test]
465    async fn test_table_name_validation_rejects_injection() {
466        let store = setup().await;
467        let err = store
468            .get("users_view; DROP TABLE users_view", "u1")
469            .await
470            .unwrap_err();
471        assert!(
472            matches!(err, ReadModelError::Other { ref message } if message.contains("table name")),
473            "expected identifier rejection, got {err:?}"
474        );
475    }
476}