Skip to main content

warden/store/
writer.rs

1//! Append-only writer. Routes each record to its month partition and writes
2//! whole lines in a single call, so a concurrent reader never sees half a
3//! record except as a torn final line.
4
5use std::collections::HashMap;
6use std::fs::{File, OpenOptions};
7use std::io::{self, Write};
8use std::path::{Path, PathBuf};
9
10use super::paths::{Partition, StorePaths};
11use super::record::{Event, IngestCursor, PromptRecord};
12
13/// Appends events, prompts and ingest cursors to the store.
14pub struct StoreWriter {
15    paths: StorePaths,
16    open: HashMap<PathBuf, File>,
17}
18
19impl StoreWriter {
20    /// Open the store at `paths`, creating `~/.warden/` and its subdirectories
21    /// `0700` if missing.
22    pub fn open(paths: StorePaths) -> io::Result<Self> {
23        create_dir_private(paths.root())?;
24        create_dir_private(&paths.events_dir())?;
25        create_dir_private(&paths.prompts_dir())?;
26        create_dir_private(&paths.state_dir())?;
27        Ok(Self {
28            paths,
29            open: HashMap::new(),
30        })
31    }
32
33    pub fn paths(&self) -> &StorePaths {
34        &self.paths
35    }
36
37    /// Append one event to `events/YYYY-MM.jsonl`, chosen by its UTC month.
38    pub fn append_event(&mut self, event: &Event) -> io::Result<Partition> {
39        let partition = Partition::for_timestamp(event.ts).ok_or_else(|| {
40            io::Error::new(
41                io::ErrorKind::InvalidInput,
42                format!(
43                    "event {} has an unrepresentable timestamp {}",
44                    event.id, event.ts
45                ),
46            )
47        })?;
48        let path = self.paths.event_partition(partition);
49        self.append_line(&path, event)?;
50        Ok(partition)
51    }
52
53    /// Append prompt text to the partition matching its event's timestamp.
54    pub fn append_prompt(&mut self, ts_ms: i64, prompt: &PromptRecord) -> io::Result<Partition> {
55        let partition = Partition::for_timestamp(ts_ms).ok_or_else(|| {
56            io::Error::new(
57                io::ErrorKind::InvalidInput,
58                format!(
59                    "prompt for event {} has an unrepresentable timestamp",
60                    prompt.event_id
61                ),
62            )
63        })?;
64        let path = self.paths.prompt_partition(partition);
65        self.append_line(&path, prompt)?;
66        Ok(partition)
67    }
68
69    /// Append an ingest cursor to `state/ingest.jsonl`.
70    pub fn append_cursor(&mut self, cursor: &IngestCursor) -> io::Result<()> {
71        let path = self.paths.ingest_state_file();
72        self.append_line(&path, cursor)
73    }
74
75    /// Serialize, then write the record and its newline in one `write_all`.
76    fn append_line<T: serde::Serialize>(&mut self, path: &Path, record: &T) -> io::Result<()> {
77        let mut line = serde_json::to_vec(record).map_err(io::Error::other)?;
78        line.push(b'\n');
79        let file = self.file_for(path)?;
80        file.write_all(&line)?;
81        file.flush()
82    }
83
84    fn file_for(&mut self, path: &Path) -> io::Result<&mut File> {
85        if !self.open.contains_key(path) {
86            let file = OpenOptions::new().create(true).append(true).open(path)?;
87            set_private(&file)?;
88            self.open.insert(path.to_path_buf(), file);
89        }
90        Ok(self.open.get_mut(path).expect("just inserted"))
91    }
92}
93
94/// Create a directory `0700`, tightening the mode if it already exists.
95fn create_dir_private(dir: &Path) -> io::Result<()> {
96    std::fs::create_dir_all(dir)?;
97    #[cfg(unix)]
98    {
99        use std::os::unix::fs::PermissionsExt;
100        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
101    }
102    Ok(())
103}
104
105/// Store files hold prompt text; keep them owner-only too.
106#[allow(unused_variables)]
107fn set_private(file: &File) -> io::Result<()> {
108    #[cfg(unix)]
109    {
110        use std::os::unix::fs::PermissionsExt;
111        file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
112    }
113    Ok(())
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::store::record::Event;
120    use chrono::{TimeZone, Utc};
121
122    fn event_at(id: &str, ts: i64) -> Event {
123        Event::new(id, ts, "claude-code", "anthropic", "assistant")
124    }
125
126    fn ms(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> i64 {
127        Utc.with_ymd_and_hms(y, mo, d, h, mi, s)
128            .unwrap()
129            .timestamp_millis()
130    }
131
132    #[test]
133    fn routes_events_to_month_partitions() {
134        let dir = tempfile::tempdir().unwrap();
135        let paths = StorePaths::new(dir.path());
136        let mut writer = StoreWriter::open(paths.clone()).unwrap();
137
138        // Last millisecond of July and the first of August, UTC.
139        let july = ms(2026, 7, 31, 23, 59, 59) + 999;
140        writer.append_event(&event_at("a", july)).unwrap();
141        writer.append_event(&event_at("b", july + 1)).unwrap();
142        writer
143            .append_event(&event_at("c", ms(2026, 8, 20, 0, 0, 0)))
144            .unwrap();
145
146        let july_lines =
147            std::fs::read_to_string(paths.event_partition(Partition::new(2026, 7))).unwrap();
148        let aug_lines =
149            std::fs::read_to_string(paths.event_partition(Partition::new(2026, 8))).unwrap();
150        assert_eq!(july_lines.lines().count(), 1);
151        assert_eq!(aug_lines.lines().count(), 2);
152        assert!(july_lines.contains("\"id\":\"a\""));
153        assert!(aug_lines.contains("\"id\":\"b\""));
154    }
155
156    #[test]
157    fn appends_rather_than_truncates_across_writers() {
158        let dir = tempfile::tempdir().unwrap();
159        let paths = StorePaths::new(dir.path());
160        let ts = ms(2026, 8, 1, 0, 0, 0);
161
162        StoreWriter::open(paths.clone())
163            .unwrap()
164            .append_event(&event_at("a", ts))
165            .unwrap();
166        StoreWriter::open(paths.clone())
167            .unwrap()
168            .append_event(&event_at("b", ts))
169            .unwrap();
170
171        let text = std::fs::read_to_string(paths.event_partition(Partition::new(2026, 8))).unwrap();
172        assert_eq!(text.lines().count(), 2);
173        assert!(text.ends_with('\n'));
174    }
175
176    #[test]
177    fn writes_prompts_and_cursors() {
178        let dir = tempfile::tempdir().unwrap();
179        let paths = StorePaths::new(dir.path());
180        let mut writer = StoreWriter::open(paths.clone()).unwrap();
181        let ts = ms(2026, 8, 1, 0, 0, 0);
182
183        writer
184            .append_prompt(
185                ts,
186                &PromptRecord {
187                    event_id: "a".into(),
188                    text: Some("hello".into()),
189                    text_hash: "h".into(),
190                },
191            )
192            .unwrap();
193        writer
194            .append_cursor(&IngestCursor {
195                path: "/logs/x.jsonl".into(),
196                mtime: ts,
197                offset: 42,
198                adapter: "claude-code".into(),
199                ts,
200            })
201            .unwrap();
202
203        let prompts =
204            std::fs::read_to_string(paths.prompt_partition(Partition::new(2026, 8))).unwrap();
205        assert!(prompts.contains("\"text_hash\":\"h\""));
206        let cursors = std::fs::read_to_string(paths.ingest_state_file()).unwrap();
207        assert!(cursors.contains("\"offset\":42"));
208    }
209
210    #[cfg(unix)]
211    #[test]
212    fn creates_store_dirs_0700() {
213        use std::os::unix::fs::PermissionsExt;
214        let dir = tempfile::tempdir().unwrap();
215        let root = dir.path().join("warden-store");
216        let paths = StorePaths::new(&root);
217        StoreWriter::open(paths.clone()).unwrap();
218        for path in [
219            paths.root().to_path_buf(),
220            paths.events_dir(),
221            paths.prompts_dir(),
222            paths.state_dir(),
223        ] {
224            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
225            assert_eq!(mode, 0o700, "{}", path.display());
226        }
227    }
228}