Skip to main content

qualia_client_core/wellfair/
journal.rs

1//! Append-only WellFair record journal (human-readable projection over WAL commits).
2
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8use wellfare_core::conditions::journal_kind_for_record_id;
9use wellfare_core::record::RecordEnvelope;
10
11pub const JOURNAL_FILE: &str = "wellfair/journal.jsonl";
12pub const MAX_LIST: usize = 256;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct JournalEntry {
16    pub id: String,
17    pub kind: String,
18    pub asserted_time_unix: u32,
19    pub evidence_type: String,
20    pub sensitivity: String,
21    pub blob_hash: Option<String>,
22    pub source: String,
23    pub committed_unix: u32,
24    /// Compact JSON projection for UI dashboards (sleep duration, weight kg, etc.).
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub summary: Option<String>,
27}
28
29impl JournalEntry {
30    pub fn from_envelope(
31        envelope: &RecordEnvelope,
32        source: &str,
33        committed_unix: u32,
34        summary: Option<String>,
35    ) -> Self {
36        let kind = infer_kind(&envelope.id);
37        Self {
38            id: envelope.id.clone(),
39            kind,
40            asserted_time_unix: envelope.asserted_time_unix,
41            evidence_type: format!("{:?}", envelope.evidence_type),
42            sensitivity: format!("{:?}", envelope.sensitivity),
43            blob_hash: envelope.blob_hash.clone(),
44            source: source.to_string(),
45            committed_unix,
46            summary,
47        }
48    }
49}
50
51fn infer_kind(record_id: &str) -> String {
52    journal_kind_for_record_id(record_id).to_string()
53}
54
55pub struct WellfairJournal {
56    path: PathBuf,
57}
58
59impl WellfairJournal {
60    pub fn open(storage_root: impl AsRef<Path>) -> std::io::Result<Self> {
61        let path = storage_root.as_ref().join(JOURNAL_FILE);
62        if let Some(parent) = path.parent() {
63            fs::create_dir_all(parent)?;
64        }
65        if !path.exists() {
66            OpenOptions::new().create(true).write(true).open(&path)?;
67        }
68        Ok(Self { path })
69    }
70
71    pub fn append(&self, entry: &JournalEntry) -> std::io::Result<()> {
72        let line =
73            serde_json::to_string(entry).map_err(|e| std::io::Error::other(e.to_string()))?;
74        let mut file = OpenOptions::new().append(true).open(&self.path)?;
75        writeln!(file, "{line}")?;
76        file.sync_all()?;
77        Ok(())
78    }
79
80    pub fn list_recent(&self, limit: usize) -> std::io::Result<Vec<JournalEntry>> {
81        let file = fs::File::open(&self.path)?;
82        let reader = BufReader::new(file);
83        let mut entries = Vec::new();
84        for line in reader.lines() {
85            let line = line?;
86            if line.trim().is_empty() {
87                continue;
88            }
89            if let Ok(entry) = serde_json::from_str::<JournalEntry>(&line) {
90                entries.push(entry);
91            }
92        }
93        let keep = limit.min(MAX_LIST);
94        if entries.len() > keep {
95            entries.drain(0..entries.len() - keep);
96        }
97        entries.reverse();
98        Ok(entries)
99    }
100
101    pub fn count(&self) -> std::io::Result<usize> {
102        let file = fs::File::open(&self.path)?;
103        Ok(BufReader::new(file)
104            .lines()
105            .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false))
106            .count())
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use wellfare_core::record::{EpistemicStatus, EvidenceType, RecordEnvelope, SensitivityClass};
114
115    #[test]
116    fn journal_round_trip() {
117        let dir = tempfile::tempdir().unwrap();
118        let journal = WellfairJournal::open(dir.path()).unwrap();
119        let envelope = RecordEnvelope {
120            id: "urn:wellfair:weight:abc".into(),
121            owner_did: "did:wf:owner".into(),
122            author_did: "did:wf:owner".into(),
123            proxy_did: None,
124            epistemic_status: EpistemicStatus::Asserted,
125            evidence_type: EvidenceType::DeviceMeasured,
126            sensitivity: SensitivityClass::Restricted,
127            asserted_time_unix: 1_700_000_000,
128            valid_time_start_unix: None,
129            valid_time_end_unix: None,
130            predecessor_id: None,
131            blob_hash: Some("deadbeef".into()),
132            tombstone: false,
133        };
134        let entry =
135            JournalEntry::from_envelope(&envelope, "companion:phone-1", 1_700_000_100, None);
136        journal.append(&entry).unwrap();
137        let listed = journal.list_recent(10).unwrap();
138        assert_eq!(listed.len(), 1);
139        assert_eq!(listed[0].kind, "weight");
140    }
141}