rovenue/cache/
store.rs

1use std::path::Path;
2use std::sync::Mutex;
3
4use rusqlite::{Connection, OptionalExtension};
5
6use crate::error::{RovenueError, RovenueResult};
7
8use super::schema::{LATEST, MIGRATIONS};
9
10#[derive(Debug, Clone)]
11pub struct SessionEventRow {
12    pub id: i64,
13    /// The wire identity the event was recorded under — it is posted under
14    /// this, never under whoever is current at flush time.
15    pub rovenue_id: String,
16    pub kind: String,
17    pub occurred_at: String,
18    pub duration_ms: Option<u32>,
19}
20
21#[derive(Debug, Clone)]
22pub struct QueuedEventRow {
23    pub id: i64,
24    pub envelope_json: String,
25}
26
27#[derive(Debug, Clone)]
28pub struct AttributeMutationRow {
29    pub id: i64,
30    /// The wire identity the mutation was made under (see SessionEventRow).
31    pub rovenue_id: String,
32    pub key: String,
33    /// None means "delete this key".
34    pub value: Option<String>,
35}
36
37pub struct CacheStore {
38    conn: Mutex<Connection>,
39}
40
41impl CacheStore {
42    pub fn open(path: &Path) -> RovenueResult<Self> {
43        let conn = Connection::open(path).map_err(|_| RovenueError::Storage())?;
44        conn.pragma_update(None, "journal_mode", "WAL").ok();
45        conn.pragma_update(None, "synchronous", "NORMAL").ok();
46        conn.pragma_update(None, "foreign_keys", "ON").ok();
47        Self::run_migrations(&conn)?;
48        Ok(Self {
49            conn: Mutex::new(conn),
50        })
51    }
52
53    pub fn open_in_memory() -> RovenueResult<Self> {
54        let conn = Connection::open_in_memory().map_err(|_| RovenueError::Storage())?;
55        Self::run_migrations(&conn)?;
56        Ok(Self {
57            conn: Mutex::new(conn),
58        })
59    }
60
61    fn run_migrations(conn: &Connection) -> RovenueResult<()> {
62        let current: u32 = conn
63            .query_row("SELECT version FROM schema_meta LIMIT 1", [], |r| r.get(0))
64            .optional()
65            .ok()
66            .flatten()
67            .unwrap_or(0);
68
69        for (idx, sql) in MIGRATIONS.iter().enumerate() {
70            let target = idx as u32 + 1;
71            if current < target {
72                conn.execute_batch(sql)
73                    .map_err(|_| RovenueError::Storage())?;
74            }
75        }
76        Ok(())
77    }
78
79    pub fn schema_version(&self) -> RovenueResult<u32> {
80        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
81        guard
82            .query_row("SELECT version FROM schema_meta LIMIT 1", [], |r| {
83                r.get::<_, u32>(0)
84            })
85            .map_err(|_| RovenueError::Storage())
86    }
87
88    pub fn has_table(&self, name: &str) -> RovenueResult<bool> {
89        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
90        let count: i64 = guard
91            .query_row(
92                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
93                [name],
94                |r| r.get(0),
95            )
96            .map_err(|_| RovenueError::Storage())?;
97        Ok(count > 0)
98    }
99
100    /// Internal accessor used by sibling modules (identity, entitlements, etag).
101    pub fn with_conn<R>(
102        &self,
103        f: impl FnOnce(&Connection) -> rusqlite::Result<R>,
104    ) -> RovenueResult<R> {
105        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
106        f(&guard).map_err(|_| RovenueError::Storage())
107    }
108
109    /// Latest schema version the binary knows how to apply.
110    pub const fn latest_schema_version() -> u32 {
111        LATEST
112    }
113
114    pub fn get_app_account_token(&self, scope: &str) -> RovenueResult<Option<String>> {
115        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
116        guard
117            .query_row(
118                "SELECT token FROM app_account_tokens WHERE scope = ?1",
119                [scope],
120                |r| r.get::<_, String>(0),
121            )
122            .optional()
123            .map_err(|_| RovenueError::Storage())
124    }
125
126    pub fn put_app_account_token(&self, scope: &str, token: &str) -> RovenueResult<()> {
127        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
128        // INSERT OR IGNORE so the first writer wins — idempotent.
129        guard
130            .execute(
131                "INSERT OR IGNORE INTO app_account_tokens (scope, token, created_at) \
132                 VALUES (?1, ?2, datetime('now'))",
133                [scope, token],
134            )
135            .map_err(|_| RovenueError::Storage())?;
136        Ok(())
137    }
138
139    /// Wipes every app account token. Used on log_out — tokens are bound to the
140    /// previous identity scope and must not carry over to the next user.
141    pub fn clear_app_account_tokens(&self) -> RovenueResult<()> {
142        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
143        guard
144            .execute("DELETE FROM app_account_tokens", [])
145            .map_err(|_| RovenueError::Storage())?;
146        Ok(())
147    }
148
149    pub fn count_app_account_tokens(&self) -> RovenueResult<i64> {
150        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
151        guard
152            .query_row("SELECT COUNT(*) FROM app_account_tokens", [], |r| r.get(0))
153            .map_err(|_| RovenueError::Storage())
154    }
155
156    pub fn append_session_event(
157        &self,
158        rovenue_id: &str,
159        kind: &str,
160        occurred_at: &str,
161        duration_ms: Option<u32>,
162    ) -> RovenueResult<()> {
163        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
164        guard
165            .execute(
166                "INSERT INTO session_events (rovenue_id, kind, occurred_at, duration_ms) \
167                 VALUES (?1, ?2, ?3, ?4)",
168                rusqlite::params![rovenue_id, kind, occurred_at, duration_ms],
169            )
170            .map_err(|_| RovenueError::Storage())?;
171        // FIFO trim — keep newest 1000.
172        guard
173            .execute(
174                "DELETE FROM session_events WHERE id NOT IN \
175                 (SELECT id FROM session_events ORDER BY id DESC LIMIT 1000)",
176                [],
177            )
178            .map_err(|_| RovenueError::Storage())?;
179        Ok(())
180    }
181
182    pub fn list_session_events(&self, limit: usize) -> RovenueResult<Vec<SessionEventRow>> {
183        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
184        let mut stmt = guard
185            .prepare(
186                "SELECT id, rovenue_id, kind, occurred_at, duration_ms FROM session_events \
187                 ORDER BY id ASC LIMIT ?1",
188            )
189            .map_err(|_| RovenueError::Storage())?;
190        let rows = stmt
191            .query_map([limit as i64], |r| {
192                Ok(SessionEventRow {
193                    id: r.get(0)?,
194                    rovenue_id: r.get(1)?,
195                    kind: r.get(2)?,
196                    occurred_at: r.get(3)?,
197                    duration_ms: r.get(4)?,
198                })
199            })
200            .map_err(|_| RovenueError::Storage())?
201            .collect::<Result<Vec<_>, _>>()
202            .map_err(|_| RovenueError::Storage())?;
203        Ok(rows)
204    }
205
206    pub fn delete_session_events(&self, ids: &[i64]) -> RovenueResult<()> {
207        if ids.is_empty() {
208            return Ok(());
209        }
210        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
211        let placeholders = std::iter::repeat_n("?", ids.len())
212            .collect::<Vec<_>>()
213            .join(",");
214        let sql = format!("DELETE FROM session_events WHERE id IN ({placeholders})");
215        let params: Vec<&dyn rusqlite::ToSql> =
216            ids.iter().map(|i| i as &dyn rusqlite::ToSql).collect();
217        guard
218            .execute(&sql, params.as_slice())
219            .map_err(|_| RovenueError::Storage())?;
220        Ok(())
221    }
222
223    /// Durable event queue (spec D4). Bounded at 100 entries —
224    /// drop-oldest — so a stuck queue (e.g. permanent connectivity loss)
225    /// cannot grow disk usage unboundedly.
226    pub fn append_queued_event(&self, envelope_json: &str) -> RovenueResult<()> {
227        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
228        guard
229            .execute(
230                "INSERT INTO event_queue (envelope_json) VALUES (?1)",
231                [envelope_json],
232            )
233            .map_err(|_| RovenueError::Storage())?;
234        // FIFO trim — keep newest 100 (bounded drop-oldest, per spec D4).
235        guard
236            .execute(
237                "DELETE FROM event_queue WHERE id NOT IN \
238                 (SELECT id FROM event_queue ORDER BY id DESC LIMIT 100)",
239                [],
240            )
241            .map_err(|_| RovenueError::Storage())?;
242        Ok(())
243    }
244
245    /// Oldest-first — the drain discipline is peek -> POST -> delete-on-2xx.
246    pub fn list_queued_events(&self, limit: usize) -> RovenueResult<Vec<QueuedEventRow>> {
247        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
248        let mut stmt = guard
249            .prepare(
250                "SELECT id, envelope_json FROM event_queue \
251                 ORDER BY id ASC LIMIT ?1",
252            )
253            .map_err(|_| RovenueError::Storage())?;
254        let rows = stmt
255            .query_map([limit as i64], |r| {
256                Ok(QueuedEventRow {
257                    id: r.get(0)?,
258                    envelope_json: r.get(1)?,
259                })
260            })
261            .map_err(|_| RovenueError::Storage())?
262            .collect::<Result<Vec<_>, _>>()
263            .map_err(|_| RovenueError::Storage())?;
264        Ok(rows)
265    }
266
267    pub fn delete_queued_events(&self, ids: &[i64]) -> RovenueResult<()> {
268        if ids.is_empty() {
269            return Ok(());
270        }
271        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
272        let placeholders = std::iter::repeat_n("?", ids.len())
273            .collect::<Vec<_>>()
274            .join(",");
275        let sql = format!("DELETE FROM event_queue WHERE id IN ({placeholders})");
276        let params: Vec<&dyn rusqlite::ToSql> =
277            ids.iter().map(|i| i as &dyn rusqlite::ToSql).collect();
278        guard
279            .execute(&sql, params.as_slice())
280            .map_err(|_| RovenueError::Storage())?;
281        Ok(())
282    }
283
284    pub fn append_attribute_mutation(
285        &self,
286        rovenue_id: &str,
287        key: &str,
288        value: Option<&str>,
289    ) -> RovenueResult<()> {
290        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
291        guard
292            .execute(
293                "INSERT INTO attribute_mutations (rovenue_id, key, value) VALUES (?1, ?2, ?3)",
294                rusqlite::params![rovenue_id, key, value],
295            )
296            .map_err(|_| RovenueError::Storage())?;
297        // FIFO trim — keep newest 1000 (backstop for an endlessly-failing flush).
298        guard
299            .execute(
300                "DELETE FROM attribute_mutations WHERE id NOT IN \
301                 (SELECT id FROM attribute_mutations ORDER BY id DESC LIMIT 1000)",
302                [],
303            )
304            .map_err(|_| RovenueError::Storage())?;
305        Ok(())
306    }
307
308    pub fn list_attribute_mutations(
309        &self,
310        limit: usize,
311    ) -> RovenueResult<Vec<AttributeMutationRow>> {
312        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
313        let mut stmt = guard
314            .prepare(
315                "SELECT id, rovenue_id, key, value FROM attribute_mutations \
316                 ORDER BY id ASC LIMIT ?1",
317            )
318            .map_err(|_| RovenueError::Storage())?;
319        let rows = stmt
320            .query_map([limit as i64], |r| {
321                Ok(AttributeMutationRow {
322                    id: r.get(0)?,
323                    rovenue_id: r.get(1)?,
324                    key: r.get(2)?,
325                    value: r.get(3)?,
326                })
327            })
328            .map_err(|_| RovenueError::Storage())?
329            .collect::<Result<Vec<_>, _>>()
330            .map_err(|_| RovenueError::Storage())?;
331        Ok(rows)
332    }
333
334    pub fn delete_attribute_mutations(&self, ids: &[i64]) -> RovenueResult<()> {
335        if ids.is_empty() {
336            return Ok(());
337        }
338        let guard = self.conn.lock().map_err(|_| RovenueError::Storage())?;
339        let placeholders = std::iter::repeat_n("?", ids.len())
340            .collect::<Vec<_>>()
341            .join(",");
342        let sql = format!("DELETE FROM attribute_mutations WHERE id IN ({placeholders})");
343        let params: Vec<&dyn rusqlite::ToSql> =
344            ids.iter().map(|i| i as &dyn rusqlite::ToSql).collect();
345        guard
346            .execute(&sql, params.as_slice())
347            .map_err(|_| RovenueError::Storage())?;
348        Ok(())
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn app_account_tokens_table_round_trip() {
358        let store = CacheStore::open_in_memory().unwrap();
359        let scope = "subscriber-abc";
360        // Returns None when empty.
361        assert_eq!(store.get_app_account_token(scope).unwrap(), None);
362        // Insert + read back.
363        store
364            .put_app_account_token(scope, "550e8400-e29b-41d4-a716-446655440000")
365            .unwrap();
366        assert_eq!(
367            store.get_app_account_token(scope).unwrap(),
368            Some("550e8400-e29b-41d4-a716-446655440000".to_string()),
369        );
370        // Idempotent: re-insert same scope is a no-op (does not overwrite).
371        store
372            .put_app_account_token(scope, "different-uuid")
373            .unwrap();
374        assert_eq!(
375            store.get_app_account_token(scope).unwrap(),
376            Some("550e8400-e29b-41d4-a716-446655440000".to_string()),
377        );
378    }
379
380    #[test]
381    fn session_events_fifo_drop_at_cap() {
382        let store = CacheStore::open_in_memory().unwrap();
383        for i in 0..1005 {
384            store
385                .append_session_event(
386                    "rov_a",
387                    "open",
388                    &format!("2026-05-28T10:00:{:02}Z", i % 60),
389                    None,
390                )
391                .unwrap();
392        }
393        let rows = store.list_session_events(2000).unwrap();
394        // FIFO drop: at cap 1000, the oldest 5 are gone — newest 1000 remain.
395        assert_eq!(rows.len(), 1000);
396        // Cleared on flush.
397        let ids: Vec<i64> = rows.iter().map(|r| r.id).collect();
398        store.delete_session_events(&ids).unwrap();
399        assert_eq!(store.list_session_events(2000).unwrap().len(), 0);
400    }
401
402    #[test]
403    fn event_queue_fifo_drop_at_cap() {
404        let store = CacheStore::open_in_memory().unwrap();
405        for i in 0..105 {
406            store
407                .append_queued_event(&format!(
408                    r#"{{"eventType":"funnel_session_started","occurredAt":"2026-05-28T10:00:00Z","eventId":"evt_{i}"}}"#
409                ))
410                .unwrap();
411        }
412        let rows = store.list_queued_events(2000).unwrap();
413        // FIFO drop: at cap 100, the oldest 5 are gone — newest 100 remain,
414        // oldest-first order preserved.
415        assert_eq!(rows.len(), 100);
416        assert!(rows[0].envelope_json.contains("\"evt_5\""));
417        assert!(rows[99].envelope_json.contains("\"evt_104\""));
418        let ids: Vec<i64> = rows.iter().map(|r| r.id).collect();
419        store.delete_queued_events(&ids).unwrap();
420        assert_eq!(store.list_queued_events(2000).unwrap().len(), 0);
421    }
422
423    #[test]
424    fn attribute_mutations_crud() {
425        let store = CacheStore::open_in_memory().unwrap();
426        store
427            .append_attribute_mutation("rov_a", "$email", Some("a@b.com"))
428            .unwrap();
429        store
430            .append_attribute_mutation("rov_a", "favoriteTeam", Some("GS"))
431            .unwrap();
432        store
433            .append_attribute_mutation("rov_b", "country", None)
434            .unwrap(); // delete marker
435
436        let rows = store.list_attribute_mutations(100).unwrap();
437        assert_eq!(rows.len(), 3);
438        assert_eq!(rows[0].key, "$email");
439        assert_eq!(rows[0].value.as_deref(), Some("a@b.com"));
440        assert_eq!(rows[2].key, "country");
441        assert_eq!(rows[2].value, None);
442        assert_eq!(rows[2].rovenue_id, "rov_b");
443
444        let ids: Vec<i64> = rows.iter().take(2).map(|r| r.id).collect();
445        store.delete_attribute_mutations(&ids).unwrap();
446        assert_eq!(store.list_attribute_mutations(100).unwrap().len(), 1);
447    }
448}