1use async_trait::async_trait;
33use std::collections::HashMap;
34use std::sync::Mutex;
35use thiserror::Error;
36
37pub type Row = serde_json::Value;
39
40#[derive(Debug, Error)]
42pub enum ReadModelError {
43 #[error("Read model write failed: {message}")]
45 WriteFailed { message: String },
46
47 #[error("Read model query failed: {message}")]
49 QueryFailed { message: String },
50
51 #[error("Read model schema operation failed: {message}")]
53 SchemaFailed { message: String },
54
55 #[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
86pub type ReadModelResult<T> = Result<T, ReadModelError>;
88
89#[derive(Debug, Clone)]
97pub struct Upsert {
98 pub table: String,
100 pub key: String,
102 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#[async_trait]
123pub trait ReadModelStore: Send + Sync {
124 async fn upsert(&self, op: Upsert) -> ReadModelResult<()>;
132
133 async fn delete(&self, table: &str, key: &str) -> ReadModelResult<()>;
135
136 async fn get(&self, table: &str, key: &str) -> ReadModelResult<Option<Row>>;
138
139 async fn find_by(
143 &self,
144 table: &str,
145 field: &str,
146 value: &serde_json::Value,
147 ) -> ReadModelResult<Vec<Row>>;
148
149 async fn list(&self, table: &str) -> ReadModelResult<Vec<Row>>;
151
152 async fn truncate(&self, table: &str) -> ReadModelResult<()>;
154}
155
156pub 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 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 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 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}