Skip to main content

forge_ui/
journal.rs

1//! Optional real NEDB persistence adapter. One immutable version holds action + resulting state.
2//! Restoration appends a new version; it never deletes or rewrites history.
3use crate::{Action, Source};
4use fs2::FileExt;
5use nedb_engine::Db;
6use serde_json::{json, Value};
7use std::{fs::File, path::Path};
8
9#[derive(Clone, Debug, serde::Serialize)]
10pub struct Receipt {
11    pub seq: u64,
12    pub hash: String,
13    pub head: String,
14}
15pub struct Journal {
16    db: Db,
17    _lock: File,
18}
19impl Journal {
20    /// Exclusive process lock prevents two showcase processes sharing the same store.
21    /// Existing data is never repaired, reset or deleted automatically.
22    pub fn open(path: &Path) -> Result<Self, String> {
23        std::fs::create_dir_all(path).map_err(|e| e.to_string())?;
24        let lock = std::fs::OpenOptions::new()
25            .create(true)
26            .truncate(false)
27            .read(true)
28            .write(true)
29            .open(path.join("forge.lock"))
30            .map_err(|e| e.to_string())?;
31        lock.try_lock_exclusive()
32            .map_err(|e| format!("store already in use or unavailable: {e}"))?;
33        let db = Db::open(path, None).map_err(|e| e.to_string())?;
34        let (_, bad) = db.verify();
35        if !bad.is_empty() {
36            return Err(format!(
37                "integrity check failed; original files preserved: {bad:?}"
38            ));
39        }
40        Ok(Self { db, _lock: lock })
41    }
42    pub fn load(&self) -> Option<Value> {
43        self.db
44            .get("forge_state", "app")
45            .map(|n| n.data["state"].clone())
46    }
47    pub fn latest(&self) -> Option<Receipt> {
48        self.db.get("forge_state", "app").map(|n| Receipt {
49            seq: n.seq,
50            hash: n.hash,
51            head: self.db.head(),
52        })
53    }
54    pub fn commit(&self, state: Value, action: &Action, source: Source) -> Result<Receipt, String> {
55        let parents = self
56            .db
57            .get("forge_state", "app")
58            .map(|n| vec![n.hash])
59            .unwrap_or_default();
60        let node = self
61            .db
62            .put(
63                "forge_state",
64                "app",
65                json!({"schema":1,"state":state,"action":action,"source":source}),
66                parents,
67                None,
68                None,
69            )
70            .map_err(|e| e.to_string())?;
71        // A successful put is not a durable acknowledgement. Both flushes must succeed.
72        self.db
73            .try_flush_all()
74            .map_err(|e| format!("state not durably acknowledged: {e}"))?;
75        self.db
76            .try_flush_manifest()
77            .map_err(|e| format!("manifest flush failed: {e}"))?;
78        let read = self
79            .db
80            .get_by_hash(&node.hash)
81            .ok_or("read-after-write integrity check failed")?;
82        if read.data != node.data {
83            return Err("read-after-write data mismatch".into());
84        }
85        Ok(Receipt {
86            seq: node.seq,
87            hash: node.hash,
88            head: self.db.head(),
89        })
90    }
91    pub fn history(&self, limit: usize) -> Vec<Value> {
92        let Some(tip) = self.db.get("forge_state", "app") else {
93            return vec![];
94        };
95        let mut nodes = self.db.trace(&tip.hash, false, limit);
96        nodes.sort_by_key(|n| std::cmp::Reverse(n.seq));
97        nodes
98            .into_iter()
99            .map(|n| json!({"seq":n.seq,"hash":n.hash,"caused_by":n.caused_by,"data":n.data}))
100            .collect()
101    }
102    pub fn state_as_of(&self, seq: u64) -> Option<Value> {
103        self.db
104            .get_as_of("forge_state", "app", seq)
105            .map(|n| n.data["state"].clone())
106    }
107    pub fn verify(&self) -> Result<usize, String> {
108        let (count, bad) = self.db.verify();
109        if bad.is_empty() {
110            Ok(count)
111        } else {
112            Err(format!("integrity errors: {bad:?}"))
113        }
114    }
115}