1use std::collections::BTreeMap;
6
7use anyhow::Context;
8use redb::{ReadableTable, TableDefinition};
9
10use super::Db;
11use crate::account_cache::CachedAccountUsage;
12
13const ACCOUNT_USAGE: TableDefinition<&str, &[u8]> = TableDefinition::new("account_usage");
14
15pub fn account_usage(db: &Db) -> anyhow::Result<BTreeMap<String, CachedAccountUsage>> {
19 let txn = db.database().begin_read()?;
20 let table = match txn.open_table(ACCOUNT_USAGE) {
21 Ok(t) => t,
22 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(BTreeMap::new()),
25 Err(e) => return Err(e.into()),
26 };
27 let mut out = BTreeMap::new();
28 for entry in table.iter()? {
29 let (name, value) = entry?;
30 match serde_json::from_slice::<CachedAccountUsage>(value.value()) {
31 Ok(usage) => {
32 out.insert(name.value().to_owned(), usage);
33 }
34 Err(err) => tracing::warn!(
35 target: "forge_workspace::store::state",
36 account = %name.value(),
37 error = %err,
38 "skipping account-usage record that failed to decode",
39 ),
40 }
41 }
42 Ok(out)
43}
44
45pub fn replace_account_usage(
48 db: &Db,
49 entries: &BTreeMap<String, CachedAccountUsage>,
50) -> anyhow::Result<()> {
51 let txn = db.database().begin_write()?;
52 {
53 let mut table = txn.open_table(ACCOUNT_USAGE)?;
54 let existing: Vec<String> = table
55 .iter()?
56 .map(|entry| entry.map(|(k, _)| k.value().to_owned()))
57 .collect::<Result<_, _>>()?;
58 for key in existing {
59 table.remove(key.as_str())?;
60 }
61 for (name, entry) in entries {
62 let value = serde_json::to_vec(entry).context("serialize account usage")?;
63 table.insert(name.as_str(), value.as_slice())?;
64 }
65 }
66 txn.commit()?;
67 Ok(())
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use forge_primitives::usage::{ApiSpend, UsageSnapshot, UsageSourceKind, UsageWindow};
74 use std::time::{Duration, SystemTime};
75 use tempfile::tempdir;
76
77 fn usage_entry(utilization: f64) -> CachedAccountUsage {
78 CachedAccountUsage {
79 snapshot: UsageSnapshot {
80 source: UsageSourceKind::Oauth,
81 fetched_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000),
82 five_hour: Some(UsageWindow {
83 utilization,
84 resets_at: None,
85 reset_description: None,
86 status: None,
87 }),
88 seven_day: None,
89 seven_day_opus: None,
90 seven_day_sonnet: None,
91 extra_usage: None,
92 spend: None,
93 balance: None,
94 },
95 }
96 }
97
98 fn usage_map(pairs: &[(&str, f64)]) -> BTreeMap<String, CachedAccountUsage> {
99 pairs.iter().map(|(name, util)| ((*name).to_owned(), usage_entry(*util))).collect()
100 }
101
102 #[test]
103 fn account_usage_replace_mirrors_the_map() {
104 let dir = tempdir().expect("tempdir");
105 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
106
107 replace_account_usage(&db, &usage_map(&[("Gateway", 42.0), ("Stargate", 10.0)]))
108 .expect("first write");
109 let loaded = account_usage(&db).expect("read");
110 assert_eq!(loaded.len(), 2);
111 assert_eq!(
112 loaded
113 .get("Gateway")
114 .and_then(|e| e.snapshot.five_hour.as_ref())
115 .map(|w| w.utilization),
116 Some(42.0),
117 );
118
119 replace_account_usage(&db, &usage_map(&[("Gateway", 99.0)])).expect("second write");
121 let loaded = account_usage(&db).expect("read again");
122 assert_eq!(loaded.len(), 1, "replace mirrors exactly the new map");
123 assert!(!loaded.contains_key("Stargate"), "the dropped account is gone");
124 assert_eq!(
125 loaded
126 .get("Gateway")
127 .and_then(|e| e.snapshot.five_hour.as_ref())
128 .map(|w| w.utilization),
129 Some(99.0),
130 "the surviving account's snapshot is the new value",
131 );
132 }
133
134 #[test]
140 fn an_old_shape_account_usage_row_still_decodes() {
141 let dir = tempdir().expect("tempdir");
142 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
143
144 let old_row = br#"{"snapshot":{
145 "source":"Oauth",
146 "fetched_at":{"secs_since_epoch":1735128000,"nanos_since_epoch":0},
147 "five_hour":{"utilization":42.0,"resets_at":null,"reset_description":null},
148 "seven_day":{"utilization":7.0,"resets_at":null,"reset_description":null},
149 "seven_day_opus":null,"seven_day_sonnet":null,
150 "extra_usage":{"monthly_limit":20.0,"used_credits":12.5,
151 "utilization":62.5,"currency":"USD"}
152 }}"#;
153
154 let txn = db.database().begin_write().expect("begin");
155 {
156 let mut table = txn.open_table(ACCOUNT_USAGE).expect("open table");
157 table.insert("Gateway", old_row.as_slice()).expect("insert old-shape row");
158 }
159 txn.commit().expect("commit");
160
161 let loaded = account_usage(&db).expect("read");
162 let entry = loaded.get("Gateway").expect("the pre-spend row survives the new shape");
163 let snapshot = &entry.snapshot;
164 assert_eq!(snapshot.source, UsageSourceKind::Oauth, "the source it carried is preserved");
165 assert_eq!(
166 snapshot.five_hour.as_ref().map(|w| w.utilization),
167 Some(42.0),
168 "the five-hour window it carried is preserved",
169 );
170 assert_eq!(
171 snapshot.seven_day.as_ref().map(|w| w.utilization),
172 Some(7.0),
173 "the seven-day window it carried is preserved",
174 );
175 assert_eq!(
176 snapshot.extra_usage.as_ref().and_then(|e| e.used_credits),
177 Some(12.5),
178 "the extra-usage block it carried is preserved",
179 );
180 assert!(snapshot.spend.is_none(), "the absent new field decodes to None");
181 }
182
183 #[test]
188 fn a_spend_row_written_before_the_cap_fields_still_decodes() {
189 let dir = tempdir().expect("tempdir");
190 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
191
192 let old_row = br#"{"snapshot":{
193 "source":"OpenRouterKey",
194 "fetched_at":{"secs_since_epoch":1735128000,"nanos_since_epoch":0},
195 "five_hour":null,"seven_day":null,
196 "seven_day_opus":null,"seven_day_sonnet":null,
197 "extra_usage":null,
198 "spend":{"daily":0.5,"weekly":1.25,"monthly":20.3}
199 }}"#;
200
201 let txn = db.database().begin_write().expect("begin");
202 {
203 let mut table = txn.open_table(ACCOUNT_USAGE).expect("open table");
204 table.insert("Api", old_row.as_slice()).expect("insert pre-cap row");
205 }
206 txn.commit().expect("commit");
207
208 let loaded = account_usage(&db).expect("read");
209 let spend = loaded
210 .get("Api")
211 .expect("the pre-cap row survives the new shape")
212 .snapshot
213 .spend
214 .as_ref()
215 .expect("the spend block it carried is preserved");
216
217 assert_eq!(
218 spend,
219 &ApiSpend {
220 daily: 0.5,
221 weekly: 1.25,
222 monthly: 20.3,
223 limit: None,
224 limit_remaining: None,
225 limit_reset: None,
226 expires_at: None,
227 },
228 "the figures it carried survive and an absent cap stays absent rather than becoming zero",
229 );
230 }
231
232 #[test]
237 fn an_account_usage_row_written_before_balance_still_decodes() {
238 let dir = tempdir().expect("tempdir");
239 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
240
241 let old_row = br#"{"snapshot":{
242 "source":"OpenRouterKey",
243 "fetched_at":{"secs_since_epoch":1735128000,"nanos_since_epoch":0},
244 "five_hour":null,"seven_day":null,
245 "seven_day_opus":null,"seven_day_sonnet":null,
246 "extra_usage":null,
247 "spend":{"daily":0.5,"weekly":1.25,"monthly":20.3}
248 }}"#;
249
250 let txn = db.database().begin_write().expect("begin");
251 {
252 let mut table = txn.open_table(ACCOUNT_USAGE).expect("open table");
253 table.insert("Api", old_row.as_slice()).expect("insert pre-balance row");
254 }
255 txn.commit().expect("commit");
256
257 let loaded = account_usage(&db).expect("read");
258 let snapshot = &loaded.get("Api").expect("the pre-balance row survives").snapshot;
259 assert!(snapshot.spend.is_some(), "the spend block it carried is preserved");
260 assert_eq!(
261 snapshot.balance, None,
262 "the absent balance decodes to None rather than failing the row",
263 );
264 }
265
266 #[test]
267 fn corrupt_account_usage_record_is_skipped_not_fatal() {
268 let dir = tempdir().expect("tempdir");
269 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
270
271 replace_account_usage(&db, &usage_map(&[("Gateway", 42.0)])).expect("write good");
272
273 let txn = db.database().begin_write().expect("begin");
275 {
276 let mut table = txn.open_table(ACCOUNT_USAGE).expect("open table");
277 table.insert("corrupt", "not usage".as_bytes()).expect("insert corrupt");
278 }
279 txn.commit().expect("commit");
280
281 let loaded = account_usage(&db).expect("read tolerates the corrupt blob");
282 assert_eq!(loaded.len(), 1, "the good record survives a corrupt sibling");
283 assert!(loaded.contains_key("Gateway"));
284 }
285
286 #[test]
287 fn state_survives_db_reopen() {
288 let dir = tempdir().expect("tempdir");
289 let path = dir.path().join("db.redb");
290 {
291 let db = Db::open(&path).expect("open db");
292 replace_account_usage(&db, &usage_map(&[("Gateway", 42.0)])).expect("write usage");
293 }
294 let db = Db::open(&path).expect("reopen db");
295 assert!(
296 account_usage(&db).expect("read").contains_key("Gateway"),
297 "the usage cache survives restart",
298 );
299 }
300}