Skip to main content

arc_core/
read_model_store.rs

1//! # Read Model Store
2//!
3//! Backend-agnostic persistence layer for projection read models.
4//!
5//! The [`ReadModelStore`] trait abstracts over storage backends (SQLite, Postgres,
6//! dqlite, in-memory) using a *typed* operation surface. Earlier iterations
7//! exposed `execute(sql, params)` and `query(sql, params)`, which leaked the
8//! SQL dialect of whichever backend was wired in — projectors written against
9//! the SQLite store would silently bind to SQLite syntax. The current trait
10//! describes intent (upsert this row keyed by id, find rows where field=value)
11//! and lets each backend translate to its own dialect.
12//!
13//! ## Operations
14//!
15//! - [`upsert`](ReadModelStore::upsert) — version-gated insert-or-replace by
16//!   primary key. The gate (`existing.version < incoming.version`) makes
17//!   projectors idempotent under duplicate or out-of-order delivery.
18//! - [`delete`](ReadModelStore::delete) — remove a row by primary key.
19//! - [`get`](ReadModelStore::get) — fetch one row by primary key.
20//! - [`find_by`](ReadModelStore::find_by) — fetch rows where a single field
21//!   equals a value (covers email lookups, secondary index reads).
22//! - [`list`](ReadModelStore::list) — fetch all rows in a table.
23//! - [`truncate`](ReadModelStore::truncate) — wipe a table during projection
24//!   rebuild.
25//!
26//! ## Implementations
27//!
28//! - [`InMemoryReadModelStore`] — built-in, for testing and ephemeral state.
29//! - `SqliteReadModelStore` (in `arc-es-sqlite`) — production SQLite.
30//! - Postgres / dqlite backends — planned.
31
32use async_trait::async_trait;
33use std::collections::HashMap;
34use std::sync::Mutex;
35use thiserror::Error;
36
37/// A row stored or returned by a read model query, represented as a JSON object.
38pub type Row = serde_json::Value;
39
40/// Errors from read model store operations.
41#[derive(Debug, Error)]
42pub enum ReadModelError {
43    /// Write operation failed.
44    #[error("Read model write failed: {message}")]
45    WriteFailed { message: String },
46
47    /// Query operation failed.
48    #[error("Read model query failed: {message}")]
49    QueryFailed { message: String },
50
51    /// Schema or truncate operation failed.
52    #[error("Read model schema operation failed: {message}")]
53    SchemaFailed { message: String },
54
55    /// Other error.
56    #[error("Read model error: {message}")]
57    Other { message: String },
58}
59
60impl ReadModelError {
61    pub fn write_failed(message: impl Into<String>) -> Self {
62        ReadModelError::WriteFailed {
63            message: message.into(),
64        }
65    }
66
67    pub fn query_failed(message: impl Into<String>) -> Self {
68        ReadModelError::QueryFailed {
69            message: message.into(),
70        }
71    }
72
73    pub fn schema_failed(message: impl Into<String>) -> Self {
74        ReadModelError::SchemaFailed {
75            message: message.into(),
76        }
77    }
78
79    pub fn other(message: impl Into<String>) -> Self {
80        ReadModelError::Other {
81            message: message.into(),
82        }
83    }
84}
85
86/// Result type for read model store operations.
87pub type ReadModelResult<T> = Result<T, ReadModelError>;
88
89/// Version-gated upsert command.
90///
91/// `version` is compared against the existing row's `version` column (if any).
92/// The store writes only when the row is absent or the existing version is
93/// strictly less than this value. That makes projectors idempotent: replaying
94/// the same event sequence twice — or receiving the same event twice through
95/// at-least-once delivery — converges to the same final state.
96#[derive(Debug, Clone)]
97pub struct Upsert {
98    /// Logical table / collection name (e.g. `"users_view"`).
99    pub table: String,
100    /// Primary key value (string-typed; backends serialize as needed).
101    pub key: String,
102    /// Full row payload as a JSON object. Must include the primary key field
103    /// and a `version` field so the store can apply the version gate.
104    pub row: Row,
105}
106
107impl Upsert {
108    pub fn new(table: impl Into<String>, key: impl Into<String>, row: Row) -> Self {
109        Self {
110            table: table.into(),
111            key: key.into(),
112            row,
113        }
114    }
115}
116
117/// Backend-agnostic storage for projection read models.
118///
119/// Implementations must be `Send + Sync`. Multiple projectors may share a
120/// store instance (different tables in the same database). Interior
121/// mutability (connection pools, `Mutex`, etc.) is expected.
122#[async_trait]
123pub trait ReadModelStore: Send + Sync {
124    /// Insert or update a row, version-gated.
125    ///
126    /// Writes only if no row exists with the given key, or the existing row's
127    /// `version` is strictly less than the incoming row's `version`. Returns
128    /// `Ok(())` whether or not the gate let the write through — the caller
129    /// (projector) does not need to distinguish "applied" from "skipped as
130    /// stale", which matters for replay semantics.
131    async fn upsert(&self, op: Upsert) -> ReadModelResult<()>;
132
133    /// Delete a row by primary key. No-op if missing.
134    async fn delete(&self, table: &str, key: &str) -> ReadModelResult<()>;
135
136    /// Fetch a single row by primary key.
137    async fn get(&self, table: &str, key: &str) -> ReadModelResult<Option<Row>>;
138
139    /// Fetch all rows where `field` equals `value`. Used for secondary-index
140    /// lookups such as login-by-email. Backends may or may not have an index
141    /// on the field — the trait carries no guarantee.
142    async fn find_by(
143        &self,
144        table: &str,
145        field: &str,
146        value: &serde_json::Value,
147    ) -> ReadModelResult<Vec<Row>>;
148
149    /// Fetch every row in a table.
150    async fn list(&self, table: &str) -> ReadModelResult<Vec<Row>>;
151
152    /// Wipe a table. Used during projection rebuild before replay.
153    async fn truncate(&self, table: &str) -> ReadModelResult<()>;
154}
155
156/// In-memory read model store for testing.
157///
158/// Stores rows keyed by primary key in a per-table `HashMap`. Implements the
159/// version gate exactly the way SQLite does, so tests written against this
160/// store exercise the same idempotency semantics as production.
161pub struct InMemoryReadModelStore {
162    tables: Mutex<HashMap<String, HashMap<String, Row>>>,
163}
164
165impl InMemoryReadModelStore {
166    pub fn new() -> Self {
167        Self {
168            tables: Mutex::new(HashMap::new()),
169        }
170    }
171
172    /// Get all rows in a table (test helper).
173    pub fn get_rows(&self, table: &str) -> Vec<Row> {
174        self.tables
175            .lock()
176            .unwrap()
177            .get(table)
178            .map(|m| m.values().cloned().collect())
179            .unwrap_or_default()
180    }
181
182    /// Total row count across all tables (test helper).
183    pub fn total_rows(&self) -> usize {
184        self.tables.lock().unwrap().values().map(|m| m.len()).sum()
185    }
186}
187
188impl Default for InMemoryReadModelStore {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194fn row_version(row: &Row) -> i64 {
195    row.get("version").and_then(|v| v.as_i64()).unwrap_or(0)
196}
197
198#[async_trait]
199impl ReadModelStore for InMemoryReadModelStore {
200    async fn upsert(&self, op: Upsert) -> ReadModelResult<()> {
201        let mut tables = self.tables.lock().unwrap();
202        let table = tables.entry(op.table).or_default();
203        let incoming_version = row_version(&op.row);
204        let should_write = match table.get(&op.key) {
205            Some(existing) => row_version(existing) < incoming_version,
206            None => true,
207        };
208        if should_write {
209            table.insert(op.key, op.row);
210        }
211        Ok(())
212    }
213
214    async fn delete(&self, table: &str, key: &str) -> ReadModelResult<()> {
215        if let Some(t) = self.tables.lock().unwrap().get_mut(table) {
216            t.remove(key);
217        }
218        Ok(())
219    }
220
221    async fn get(&self, table: &str, key: &str) -> ReadModelResult<Option<Row>> {
222        Ok(self
223            .tables
224            .lock()
225            .unwrap()
226            .get(table)
227            .and_then(|t| t.get(key).cloned()))
228    }
229
230    async fn find_by(
231        &self,
232        table: &str,
233        field: &str,
234        value: &serde_json::Value,
235    ) -> ReadModelResult<Vec<Row>> {
236        Ok(self
237            .tables
238            .lock()
239            .unwrap()
240            .get(table)
241            .map(|t| {
242                t.values()
243                    .filter(|row| row.get(field) == Some(value))
244                    .cloned()
245                    .collect()
246            })
247            .unwrap_or_default())
248    }
249
250    async fn list(&self, table: &str) -> ReadModelResult<Vec<Row>> {
251        Ok(self.get_rows(table))
252    }
253
254    async fn truncate(&self, table: &str) -> ReadModelResult<()> {
255        self.tables.lock().unwrap().remove(table);
256        Ok(())
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use serde_json::json;
264
265    fn row(id: &str, name: &str, version: i64) -> Row {
266        json!({"id": id, "name": name, "version": version})
267    }
268
269    #[tokio::test]
270    async fn test_upsert_inserts_when_absent() {
271        let store = InMemoryReadModelStore::new();
272        store
273            .upsert(Upsert::new("users_view", "u1", row("u1", "Alice", 1)))
274            .await
275            .unwrap();
276
277        let got = store.get("users_view", "u1").await.unwrap().unwrap();
278        assert_eq!(got["name"], "Alice");
279    }
280
281    #[tokio::test]
282    async fn test_upsert_replaces_when_version_advances() {
283        let store = InMemoryReadModelStore::new();
284        store
285            .upsert(Upsert::new("users_view", "u1", row("u1", "Alice", 1)))
286            .await
287            .unwrap();
288        store
289            .upsert(Upsert::new("users_view", "u1", row("u1", "Alice2", 2)))
290            .await
291            .unwrap();
292
293        let got = store.get("users_view", "u1").await.unwrap().unwrap();
294        assert_eq!(got["name"], "Alice2");
295        assert_eq!(got["version"], 2);
296    }
297
298    #[tokio::test]
299    async fn test_upsert_skips_when_version_stale() {
300        // Pinned: replay must not regress newer state.
301        let store = InMemoryReadModelStore::new();
302        store
303            .upsert(Upsert::new("users_view", "u1", row("u1", "Alice2", 2)))
304            .await
305            .unwrap();
306        store
307            .upsert(Upsert::new("users_view", "u1", row("u1", "Alice1", 1)))
308            .await
309            .unwrap();
310
311        let got = store.get("users_view", "u1").await.unwrap().unwrap();
312        assert_eq!(got["name"], "Alice2");
313        assert_eq!(got["version"], 2);
314    }
315
316    #[tokio::test]
317    async fn test_find_by_returns_matching_rows() {
318        let store = InMemoryReadModelStore::new();
319        store
320            .upsert(Upsert::new(
321                "users_view",
322                "u1",
323                json!({"id": "u1", "email": "a@b.c", "version": 1}),
324            ))
325            .await
326            .unwrap();
327        store
328            .upsert(Upsert::new(
329                "users_view",
330                "u2",
331                json!({"id": "u2", "email": "x@y.z", "version": 1}),
332            ))
333            .await
334            .unwrap();
335
336        let hits = store
337            .find_by("users_view", "email", &json!("a@b.c"))
338            .await
339            .unwrap();
340        assert_eq!(hits.len(), 1);
341        assert_eq!(hits[0]["id"], "u1");
342    }
343
344    #[tokio::test]
345    async fn test_delete_and_truncate() {
346        let store = InMemoryReadModelStore::new();
347        store
348            .upsert(Upsert::new("users_view", "u1", row("u1", "Alice", 1)))
349            .await
350            .unwrap();
351        store
352            .upsert(Upsert::new("users_view", "u2", row("u2", "Bob", 1)))
353            .await
354            .unwrap();
355
356        store.delete("users_view", "u1").await.unwrap();
357        assert!(store.get("users_view", "u1").await.unwrap().is_none());
358        assert_eq!(store.list("users_view").await.unwrap().len(), 1);
359
360        store.truncate("users_view").await.unwrap();
361        assert_eq!(store.total_rows(), 0);
362    }
363}