Skip to main content

vanta_state/
lib.rs

1//! `vanta-state` — the redb-backed persistent state store.
2//!
3//! Holds the index/history/cache tables described in
4//! `docs/23-data-and-state-model.md`: the store index, generation history, GC
5//! roots, the resolution cache, and a `meta` table (schema version + the current
6//! generation pointer). Records are serialized to bytes with `serde_json` and
7//! stored under typed redb tables; all writes are transactional.
8//!
9//! The store/caches (raw content-addressed bytes) live on the filesystem, not
10//! here — this crate is metadata only.
11#![forbid(unsafe_code)]
12
13use redb::{Database, ReadableTable, TableDefinition};
14use serde::{Deserialize, Serialize};
15use std::fmt::Display;
16use std::path::Path;
17use vanta_core::{Area, VtaError, VtaResult};
18
19/// The state-DB schema version (`docs/23` §schema-versioning-and-migration).
20pub const SCHEMA_VERSION: u32 = 1;
21
22const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
23const STORE_INDEX: TableDefinition<&str, &[u8]> = TableDefinition::new("store_index");
24const GENERATIONS: TableDefinition<u64, &[u8]> = TableDefinition::new("generations");
25const RESOLUTION_CACHE: TableDefinition<&str, &[u8]> = TableDefinition::new("resolution_cache");
26
27/// Metadata catalogued for a materialized store entry (`docs/23`).
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct StoreEntryMeta {
30    pub tool: String,
31    pub version: String,
32    pub platform: String,
33    pub size: u64,
34    pub sha256: String,
35}
36
37/// An immutable generation record (`docs/12-updates.md`, `docs/23`).
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct GenerationRecord {
40    pub id: u64,
41    pub parent: Option<u64>,
42    pub command: String,
43    pub reason: String,
44    /// tool name → store key.
45    pub tools: Vec<(String, String)>,
46}
47
48/// The persistent state database.
49pub struct State {
50    db: Database,
51}
52
53impl State {
54    /// Open (creating if absent) the state database at `path` and ensure the
55    /// schema-version marker is present.
56    pub fn open(path: &Path) -> VtaResult<State> {
57        let db = Database::create(path).map_err(db_err)?;
58        let state = State { db };
59        state.init()?;
60        Ok(state)
61    }
62
63    fn init(&self) -> VtaResult<()> {
64        let txn = self.db.begin_write().map_err(db_err)?;
65        {
66            let mut meta = txn.open_table(META).map_err(db_err)?;
67            if meta.get("schema_version").map_err(db_err)?.is_none() {
68                meta.insert("schema_version", SCHEMA_VERSION.to_string().as_bytes())
69                    .map_err(db_err)?;
70            }
71        }
72        txn.commit().map_err(db_err)?;
73        Ok(())
74    }
75
76    /// The on-disk schema version (0 if unset).
77    pub fn schema_version(&self) -> VtaResult<u32> {
78        self.meta_get("schema_version")
79            .map(|opt| opt.and_then(|s| s.parse().ok()).unwrap_or(0))
80    }
81
82    /// Record (or replace) the metadata for a store entry, keyed by its store key.
83    pub fn put_store_entry(&self, store_key: &str, meta: &StoreEntryMeta) -> VtaResult<()> {
84        let bytes = serde_json::to_vec(meta).map_err(enc_err)?;
85        let txn = self.db.begin_write().map_err(db_err)?;
86        {
87            let mut t = txn.open_table(STORE_INDEX).map_err(db_err)?;
88            t.insert(store_key, bytes.as_slice()).map_err(db_err)?;
89        }
90        txn.commit().map_err(db_err)?;
91        Ok(())
92    }
93
94    /// Look up store-entry metadata by store key.
95    pub fn get_store_entry(&self, store_key: &str) -> VtaResult<Option<StoreEntryMeta>> {
96        let txn = self.db.begin_read().map_err(db_err)?;
97        let t = txn.open_table(STORE_INDEX).map_err(db_err)?;
98        match t.get(store_key).map_err(db_err)? {
99            Some(guard) => Ok(Some(
100                serde_json::from_slice(guard.value()).map_err(enc_err)?,
101            )),
102            None => Ok(None),
103        }
104    }
105
106    /// Append a generation record (keyed by its id).
107    pub fn append_generation(&self, rec: &GenerationRecord) -> VtaResult<()> {
108        let bytes = serde_json::to_vec(rec).map_err(enc_err)?;
109        let txn = self.db.begin_write().map_err(db_err)?;
110        {
111            let mut t = txn.open_table(GENERATIONS).map_err(db_err)?;
112            t.insert(rec.id, bytes.as_slice()).map_err(db_err)?;
113        }
114        txn.commit().map_err(db_err)?;
115        Ok(())
116    }
117
118    /// Fetch a generation record by id.
119    pub fn get_generation(&self, id: u64) -> VtaResult<Option<GenerationRecord>> {
120        let txn = self.db.begin_read().map_err(db_err)?;
121        let t = txn.open_table(GENERATIONS).map_err(db_err)?;
122        match t.get(id).map_err(db_err)? {
123            Some(guard) => Ok(Some(
124                serde_json::from_slice(guard.value()).map_err(enc_err)?,
125            )),
126            None => Ok(None),
127        }
128    }
129
130    /// Set the current (active) generation pointer.
131    pub fn set_current(&self, id: u64) -> VtaResult<()> {
132        let txn = self.db.begin_write().map_err(db_err)?;
133        {
134            let mut meta = txn.open_table(META).map_err(db_err)?;
135            meta.insert("current", id.to_string().as_bytes())
136                .map_err(db_err)?;
137        }
138        txn.commit().map_err(db_err)?;
139        Ok(())
140    }
141
142    /// The current (active) generation id, if any.
143    pub fn current(&self) -> VtaResult<Option<u64>> {
144        self.meta_get("current")
145            .map(|opt| opt.and_then(|s| s.parse().ok()))
146    }
147
148    /// Store a raw resolution-cache entry (opaque bytes keyed by a config hash).
149    pub fn put_resolution(&self, config_hash: &str, bytes: &[u8]) -> VtaResult<()> {
150        let txn = self.db.begin_write().map_err(db_err)?;
151        {
152            let mut t = txn.open_table(RESOLUTION_CACHE).map_err(db_err)?;
153            t.insert(config_hash, bytes).map_err(db_err)?;
154        }
155        txn.commit().map_err(db_err)?;
156        Ok(())
157    }
158
159    /// Fetch a raw resolution-cache entry.
160    pub fn get_resolution(&self, config_hash: &str) -> VtaResult<Option<Vec<u8>>> {
161        let txn = self.db.begin_read().map_err(db_err)?;
162        let t = txn.open_table(RESOLUTION_CACHE).map_err(db_err)?;
163        Ok(t.get(config_hash)
164            .map_err(db_err)?
165            .map(|g| g.value().to_vec()))
166    }
167
168    fn meta_get(&self, key: &str) -> VtaResult<Option<String>> {
169        let txn = self.db.begin_read().map_err(db_err)?;
170        let t = txn.open_table(META).map_err(db_err)?;
171        Ok(t.get(key)
172            .map_err(db_err)?
173            .map(|g| String::from_utf8_lossy(g.value()).into_owned()))
174    }
175}
176
177fn db_err<E: Display>(e: E) -> VtaError {
178    VtaError::new(Area::Store, 10, format!("state db: {e}"))
179}
180
181fn enc_err<E: Display>(e: E) -> VtaError {
182    VtaError::new(Area::Store, 11, format!("state encode/decode: {e}"))
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn temp_db(tag: &str) -> std::path::PathBuf {
190        let p =
191            std::env::temp_dir().join(format!("vanta-state-{}-{}.redb", tag, std::process::id()));
192        let _ = std::fs::remove_file(&p);
193        p
194    }
195
196    #[test]
197    fn schema_initialized() {
198        let path = temp_db("schema");
199        let s = State::open(&path).unwrap();
200        assert_eq!(s.schema_version().unwrap(), SCHEMA_VERSION);
201        let _ = std::fs::remove_file(&path);
202    }
203
204    #[test]
205    fn store_entry_roundtrip() {
206        let path = temp_db("store");
207        let s = State::open(&path).unwrap();
208        let meta = StoreEntryMeta {
209            tool: "node".into(),
210            version: "24.6.0".into(),
211            platform: "macos/aarch64".into(),
212            size: 24117248,
213            sha256: "5f2c".into(),
214        };
215        s.put_store_entry("blake3-aa3f", &meta).unwrap();
216        assert_eq!(s.get_store_entry("blake3-aa3f").unwrap(), Some(meta));
217        assert_eq!(s.get_store_entry("blake3-missing").unwrap(), None);
218        let _ = std::fs::remove_file(&path);
219    }
220
221    #[test]
222    fn generations_and_current() {
223        let path = temp_db("gen");
224        let s = State::open(&path).unwrap();
225        let rec = GenerationRecord {
226            id: 1,
227            parent: None,
228            command: "vanta add node@24".into(),
229            reason: "add".into(),
230            tools: vec![("node".into(), "blake3-aa3f".into())],
231        };
232        s.append_generation(&rec).unwrap();
233        s.set_current(1).unwrap();
234        assert_eq!(s.current().unwrap(), Some(1));
235        assert_eq!(s.get_generation(1).unwrap(), Some(rec));
236        assert_eq!(s.get_generation(2).unwrap(), None);
237        let _ = std::fs::remove_file(&path);
238    }
239}