Skip to main content

forge_workspace/store/
plugins.rs

1//! Plugin update history on the redb `plugin_updates` table: one row
2//! per installed entry (key `plugin_id|scope`), holding the latest
3//! update forge applied to it - what moved, from where, and the
4//! marketplace ref a rollback can restore. Values are serde-json.
5
6use std::collections::BTreeMap;
7
8use anyhow::Context;
9use redb::{ReadableTable, TableDefinition};
10
11use super::Db;
12use forge_primitives::plugins::PluginUpdateRecord;
13
14const PLUGIN_UPDATES: TableDefinition<&str, &[u8]> = TableDefinition::new("plugin_updates");
15
16/// The store key for one installed entry.
17fn record_key(plugin_id: &str, scope: &str) -> String {
18    format!("{plugin_id}|{scope}")
19}
20
21/// Persist one batch of update outcomes in a single transaction,
22/// replacing any earlier record for the same installed entries. A
23/// mid-batch failure leaves nothing half-written.
24pub fn record_updates(db: &Db, records: &[PluginUpdateRecord]) -> anyhow::Result<()> {
25    let txn = db.database().begin_write()?;
26    {
27        let mut table = txn.open_table(PLUGIN_UPDATES)?;
28        for record in records {
29            let value = serde_json::to_vec(record).context("serialize plugin update record")?;
30            table
31                .insert(record_key(&record.plugin_id, &record.scope).as_str(), value.as_slice())?;
32        }
33    }
34    txn.commit()?;
35    Ok(())
36}
37
38/// Every remembered update, keyed by `plugin_id|scope`. A record that
39/// fails to decode is skipped with a warn so one corrupt blob can't
40/// take the history down.
41pub fn update_records(db: &Db) -> anyhow::Result<BTreeMap<String, PluginUpdateRecord>> {
42    let txn = db.database().begin_read()?;
43    let table = match txn.open_table(PLUGIN_UPDATES) {
44        Ok(t) => t,
45        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(BTreeMap::new()),
46        Err(e) => return Err(e.into()),
47    };
48    let mut out = BTreeMap::new();
49    for entry in table.iter()? {
50        let (key, value) = entry?;
51        match serde_json::from_slice::<PluginUpdateRecord>(value.value()) {
52            Ok(record) => {
53                out.insert(key.value().to_owned(), record);
54            }
55            Err(err) => tracing::warn!(
56                target: "forge_workspace::store::plugins",
57                key = %key.value(),
58                error = %err,
59                "skipping plugin update record that failed to decode",
60            ),
61        }
62    }
63    Ok(out)
64}
65
66/// Drop the record for one installed entry - the rollback a record
67/// made possible has been used, and there is no earlier version to go
68/// back to behind it.
69pub fn clear_update_record(db: &Db, plugin_id: &str, scope: &str) -> anyhow::Result<()> {
70    let txn = db.database().begin_write()?;
71    {
72        let mut table = txn.open_table(PLUGIN_UPDATES)?;
73        table.remove(record_key(plugin_id, scope).as_str())?;
74    }
75    txn.commit()?;
76    Ok(())
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use tempfile::tempdir;
83
84    fn record(plugin_id: &str, to_version: &str) -> PluginUpdateRecord {
85        PluginUpdateRecord {
86            plugin_id: plugin_id.to_owned(),
87            marketplace: "probe-market".to_owned(),
88            scope: "user".to_owned(),
89            cwd_raw: "/proj".to_owned(),
90            from_version: Some("0.1.0".to_owned()),
91            to_version: Some(to_version.to_owned()),
92            marketplace_ref_before: Some("2d7d4c6".to_owned()),
93            updated_at: "2026-09-04T06:00:00Z".to_owned(),
94            trigger: forge_primitives::plugins::PluginUpdateTrigger::Auto,
95        }
96    }
97
98    #[test]
99    fn records_round_trip_and_replace_by_key() {
100        let dir = tempdir().expect("tempdir");
101        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
102
103        record_updates(&db, &[record("hello@probe-market", "0.2.0")]).expect("write");
104        record_updates(&db, &[record("pensive@claude-night-market", "1.7.3")]).expect("write");
105        record_updates(&db, &[record("hello@probe-market", "0.3.0")]).expect("overwrite");
106
107        let records = update_records(&db).expect("read");
108        assert_eq!(records.len(), 2, "one row per installed entry");
109        assert_eq!(records["hello@probe-market|user"].to_version.as_deref(), Some("0.3.0"));
110        assert_eq!(records["hello@probe-market|user"].marketplace, "probe-market");
111    }
112
113    #[test]
114    fn records_survive_db_reopen() {
115        let dir = tempdir().expect("tempdir");
116        let path = dir.path().join("db.redb");
117        {
118            let db = Db::open(&path).expect("open db");
119            record_updates(&db, &[record("hello@probe-market", "0.2.0")]).expect("write");
120        }
121        let reopened = Db::open(&path).expect("reopen db");
122        let records = update_records(&reopened).expect("read");
123        assert_eq!(records.len(), 1);
124    }
125
126    #[test]
127    fn an_empty_store_reads_as_no_records() {
128        let dir = tempdir().expect("tempdir");
129        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
130        assert!(update_records(&db).expect("read").is_empty());
131    }
132
133    /// A consumed rollback drops its record; the record's promise is
134    /// one rollback, not a permanent one.
135    #[test]
136    fn clear_removes_only_the_target_record() {
137        let dir = tempdir().expect("tempdir");
138        let db = Db::open(&dir.path().join("db.redb")).expect("open db");
139        record_updates(
140            &db,
141            &[
142                record("hello@probe-market", "0.2.0"),
143                record("pensive@claude-night-market", "1.7.3"),
144            ],
145        )
146        .expect("write");
147
148        clear_update_record(&db, "hello@probe-market", "user").expect("clear");
149
150        let records = update_records(&db).expect("read");
151        assert_eq!(records.len(), 1, "only the rolled-back entry is forgotten");
152        assert!(records.contains_key("pensive@claude-night-market|user"));
153    }
154}