1use 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
13pub struct StoreWriter {
15 paths: StorePaths,
16 open: HashMap<PathBuf, File>,
17}
18
19impl StoreWriter {
20 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 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 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 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 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
94fn 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#[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 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}