forge_workspace/store/
gotify.rs1use anyhow::Context;
8use forge_primitives::GotifySubscription;
9use redb::{ReadableTable, TableDefinition};
10use uuid::Uuid;
11
12use super::Db;
13
14const SUBS: TableDefinition<[u8; 16], &[u8]> = TableDefinition::new("subscriptions");
15
16pub fn insert(db: &Db, sub: &GotifySubscription) -> anyhow::Result<()> {
18 let value = serde_json::to_vec(sub).context("serialize subscription")?;
19 let txn = db.database().begin_write()?;
20 {
21 let mut table = txn.open_table(SUBS)?;
22 table.insert(sub.id.as_bytes(), value.as_slice())?;
23 }
24 txn.commit()?;
25 Ok(())
26}
27
28pub fn list(db: &Db) -> anyhow::Result<Vec<GotifySubscription>> {
30 read_all(db)
31}
32
33pub fn remove(db: &Db, id: Uuid) -> anyhow::Result<bool> {
35 let txn = db.database().begin_write()?;
36 let existed = {
37 let mut table = txn.open_table(SUBS)?;
38 table.remove(id.as_bytes())?.is_some()
39 };
40 txn.commit()?;
41 Ok(existed)
42}
43
44fn read_all(db: &Db) -> anyhow::Result<Vec<GotifySubscription>> {
45 let txn = db.database().begin_read()?;
46 let table = match txn.open_table(SUBS) {
47 Ok(t) => t,
48 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
51 Err(e) => return Err(e.into()),
52 };
53 let mut out = Vec::new();
54 for entry in table.iter()? {
55 let (id, value) = entry?;
56 match serde_json::from_slice::<GotifySubscription>(value.value()) {
57 Ok(sub) => out.push(sub),
58 Err(err) => tracing::warn!(
61 target: "forge_workspace::store::gotify",
62 id = %Uuid::from_bytes(id.value()),
63 error = %err,
64 "skipping Gotify subscription record that failed to decode",
65 ),
66 }
67 }
68 Ok(out)
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74 use std::time::SystemTime;
75 use tempfile::tempdir;
76
77 fn sub(project: &str) -> GotifySubscription {
78 GotifySubscription {
79 id: Uuid::new_v4(),
80 project: project.to_owned(),
81 team_role: None,
82 applications: vec![],
83 min_priority: None,
84 created_at: SystemTime::UNIX_EPOCH,
85 }
86 }
87
88 #[test]
89 fn subscription_store_round_trip() {
90 let dir = tempdir().expect("tempdir");
91 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
92
93 let s1 = sub("p1");
94 let s2 = sub("p2");
95 insert(&db, &s1).expect("insert s1");
96 insert(&db, &s2).expect("insert s2");
97
98 let all = list(&db).expect("list");
99 assert_eq!(all.len(), 2);
100 assert!(all.iter().any(|s| s.id == s1.id) && all.iter().any(|s| s.id == s2.id));
101
102 assert!(remove(&db, s1.id).expect("remove"), "an existing id removes to true");
103 assert!(!remove(&db, s1.id).expect("remove again"), "an absent id removes to false");
104 assert_eq!(list(&db).expect("list").len(), 1);
105 }
106
107 #[test]
108 fn dynamic_worker_subscription_survives_db_reopen() {
109 let dir = tempdir().expect("tempdir");
110 let path = dir.path().join("db.redb");
111
112 let mut worker_sub = sub("forge");
113 worker_sub.team_role = Some("scratch".to_owned());
114 let id = worker_sub.id;
115 {
116 let db = Db::open(&path).expect("open db");
117 insert(&db, &worker_sub).expect("insert");
118 }
119
120 let db = Db::open(&path).expect("reopen db");
123 let restored = list(&db).expect("list after reopen");
124 assert_eq!(restored.len(), 1);
125 assert_eq!(restored[0].id, id);
126 assert_eq!(
127 restored[0].team_role.as_deref(),
128 Some("scratch"),
129 "a dynamic worker's durable sub survives a restart with its label intact",
130 );
131 }
132
133 #[test]
134 fn corrupt_record_is_skipped_not_fatal() {
135 let dir = tempdir().expect("tempdir");
136 let db = Db::open(&dir.path().join("db.redb")).expect("open db");
137
138 let good = sub("p1");
139 insert(&db, &good).expect("insert good");
140
141 let txn = db.database().begin_write().expect("begin");
143 {
144 let mut table = txn.open_table(SUBS).expect("open table");
145 table.insert([0u8; 16], "not a subscription".as_bytes()).expect("insert corrupt");
146 }
147 txn.commit().expect("commit");
148
149 let all = list(&db).expect("list tolerates the corrupt blob");
150 assert_eq!(all.len(), 1, "the good record survives a corrupt sibling");
151 assert_eq!(all[0].id, good.id);
152 }
153}