Skip to main content

vs_store/store/
actions.rs

1//! `actions` table: audit log + idempotency cache lookup.
2
3use rusqlite::params;
4
5use super::Store;
6use crate::error::Result;
7use crate::types::{Action, ActionFilter, ActionInsert};
8
9impl Store {
10    /// Insert one row into `actions`. Returns the autoincrement id.
11    pub fn record_action(&mut self, ins: &ActionInsert) -> Result<i64> {
12        self.conn().execute(
13            "INSERT INTO actions(
14                session_id, page_id, primitive, args_redacted, args_hash,
15                before_token, after_token, idempotency_hit, result_summary,
16                latency_ms, group_label, started_at, finished_at, error_code
17            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
18            params![
19                ins.session_id,
20                ins.page_id,
21                ins.primitive,
22                ins.args_redacted,
23                ins.args_hash,
24                ins.before_token,
25                ins.after_token,
26                i64::from(ins.idempotency_hit),
27                ins.result_summary,
28                ins.latency_ms,
29                ins.group_label,
30                ins.started_at,
31                ins.finished_at,
32                ins.error_code,
33            ],
34        )?;
35        Ok(self.conn().last_insert_rowid())
36    }
37
38    /// Look up the most recent successful action whose
39    /// `(page_id, before_token, args_hash)` triple matches and whose
40    /// `started_at` is within `ttl_secs` of `now_secs`. The caller
41    /// supplies `now_secs` for testability; production calls
42    /// [`super::epoch_secs`].
43    pub fn lookup_idempotent(
44        &self,
45        page_id: &str,
46        before_token: &str,
47        args_hash: &str,
48        now_secs: i64,
49        ttl_secs: i64,
50    ) -> Result<Option<Action>> {
51        let mut stmt = self.conn().prepare(
52            "SELECT * FROM actions
53              WHERE page_id=?1 AND before_token=?2 AND args_hash=?3
54                AND error_code IS NULL
55                AND idempotency_hit=0
56                AND started_at >= ?4
57              ORDER BY started_at DESC
58              LIMIT 1",
59        )?;
60        let cutoff = now_secs - ttl_secs;
61        let mut rows = stmt.query(params![page_id, before_token, args_hash, cutoff])?;
62        if let Some(row) = rows.next()? {
63            Ok(Some(Action::from_row(row)?))
64        } else {
65            Ok(None)
66        }
67    }
68
69    pub fn list_actions(&self, filter: &ActionFilter) -> Result<Vec<Action>> {
70        use std::fmt::Write as _;
71        let mut sql = String::from("SELECT * FROM actions WHERE 1=1");
72        let mut args: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
73        let mut idx = 1usize;
74        if let Some(s) = &filter.session_id {
75            write!(sql, " AND session_id=?{idx}").expect("write to String");
76            args.push(Box::new(s.clone()));
77            idx += 1;
78        }
79        if let Some(p) = &filter.page_id {
80            write!(sql, " AND page_id=?{idx}").expect("write to String");
81            args.push(Box::new(p.clone()));
82            idx += 1;
83        }
84        if let Some(g) = &filter.group_label {
85            write!(sql, " AND group_label=?{idx}").expect("write to String");
86            args.push(Box::new(g.clone()));
87            idx += 1;
88        }
89        if let Some(t) = filter.since_started_at {
90            write!(sql, " AND started_at >= ?{idx}").expect("write to String");
91            args.push(Box::new(t));
92            idx += 1;
93        }
94        sql.push_str(" ORDER BY started_at ASC");
95        if let Some(limit) = filter.limit {
96            write!(sql, " LIMIT ?{idx}").expect("write to String");
97            args.push(Box::new(limit));
98        }
99        let mut stmt = self.conn().prepare(&sql)?;
100        let arg_refs: Vec<&dyn rusqlite::ToSql> =
101            args.iter().map(std::convert::AsRef::as_ref).collect();
102        let rows = stmt.query_map(arg_refs.as_slice(), Action::from_row)?;
103        Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
104    }
105}