forge_workspace/store/
plugins.rs1use 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
16fn record_key(plugin_id: &str, scope: &str) -> String {
18 format!("{plugin_id}|{scope}")
19}
20
21pub 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
38pub 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
66pub 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 #[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}