Skip to main content

forge_workspace/store/
task_archive.rs

1//! Durable archive: task rows whose tree has closed.
2//!
3//! Append-only, and never read by the live board - the retro and any
4//! history view read here. Rows leave only when their project is deleted.
5
6use anyhow::Context;
7use forge_primitives::tasks::Task;
8use redb::{ReadableTable, TableDefinition};
9
10use super::Db;
11
12const TASK_ARCHIVE: TableDefinition<&str, &[u8]> = TableDefinition::new("tasks_archive");
13
14/// The row key for `task`: its project as well as its id.
15fn row_key(task: &Task) -> String {
16    format!("{}\u{0}{}", task.project_name, task.id.as_str())
17}
18
19/// Append `tasks`; never rewrites what is there.
20pub fn append(db: &Db, tasks: &[Task]) -> anyhow::Result<()> {
21    let txn = db.database().begin_write()?;
22    {
23        let mut table = txn.open_table(TASK_ARCHIVE)?;
24        for task in tasks {
25            let value = serde_json::to_vec(task).context("serialize archived task")?;
26            table.insert(row_key(task).as_str(), value.as_slice())?;
27        }
28    }
29    txn.commit()?;
30    Ok(())
31}
32
33/// `project`'s archived rows.
34pub fn list_for_project(db: &Db, project: &str) -> anyhow::Result<Vec<Task>> {
35    let txn = db.database().begin_read()?;
36    let table = match txn.open_table(TASK_ARCHIVE) {
37        Ok(t) => t,
38        Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
39        Err(e) => return Err(e.into()),
40    };
41    let prefix = format!("{project}\u{0}");
42    let mut out = Vec::new();
43    for entry in table.iter()? {
44        let (key, value) = entry?;
45        if !key.value().starts_with(&prefix) {
46            continue;
47        }
48        match serde_json::from_slice::<Task>(value.value()) {
49            Ok(task) => out.push(task),
50            Err(err) => tracing::warn!(
51                target: "forge_workspace::store::task_archive",
52                row = %key.value(),
53                error = %err,
54                "skipping archived row that failed to decode",
55            ),
56        }
57    }
58    Ok(out)
59}
60
61#[cfg(test)]
62mod tests {
63    use std::path::Path;
64
65    use super::*;
66    use forge_primitives::tasks::{Task, TaskId, TaskStatus};
67    use tempfile::tempdir;
68
69    fn open_db(dir: &Path) -> Db {
70        Db::open(&dir.join("db.redb")).expect("open db")
71    }
72
73    fn archived_task(id: &str, project: &str) -> Task {
74        Task {
75            id: TaskId::from(id),
76            project_name: project.to_owned(),
77            subject: format!("subject {id}"),
78            active_form: None,
79            detail: None,
80            status: TaskStatus::Completed,
81            owner: None,
82            parent: None,
83            waiting_on: None,
84            estimate: None,
85            rank: None,
86            verify: None,
87            attested: None,
88            reserved: None,
89            links: Vec::new(),
90            attempt: 0,
91            archived_at: Some(std::time::SystemTime::UNIX_EPOCH),
92            created_at: std::time::SystemTime::UNIX_EPOCH,
93            updated_at: std::time::SystemTime::UNIX_EPOCH,
94        }
95    }
96
97    #[test]
98    fn archived_rows_append_and_read_back_for_their_project() {
99        let dir = tempdir().expect("tempdir");
100        let db = open_db(dir.path());
101        append(&db, &[archived_task("t-1", "forge"), archived_task("t-2", "other")])
102            .expect("append");
103        let forge = list_for_project(&db, "forge").expect("list");
104        assert_eq!(forge.len(), 1, "only forge's archived rows: {forge:?}");
105        assert_eq!(forge[0].id, TaskId::from("t-1"));
106        assert!(forge[0].archived_at.is_some(), "an archived row carries its stamp");
107    }
108
109    #[test]
110    fn an_absent_archive_table_reads_as_empty() {
111        let dir = tempdir().expect("tempdir");
112        let db = open_db(dir.path());
113        assert!(list_for_project(&db, "forge").expect("list").is_empty());
114    }
115}