1use std::io::Write as _;
20
21use serde::{Deserialize, Serialize};
22
23use super::{Durability, Storage};
24
25pub const DIR: &str = "history";
26
27const SESSIONS: &str = "history/sessions.jsonl";
29
30#[derive(Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
33pub struct Entry {
34 pub ts: u64,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub command: Option<String>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 pub changed: Vec<String>,
44}
45
46#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
55pub struct Session {
56 pub ts: u64,
57 pub seq: u64,
58}
59
60pub fn record_session(storage: &impl Storage, session: &Session) -> std::io::Result<()> {
67 let mut lines = storage.read(SESSIONS).unwrap_or_default();
68 if !lines.is_empty() && !lines.ends_with(b"\n") {
69 lines.push(b'\n');
70 }
71 serde_json::to_writer(&mut lines, session).map_err(std::io::Error::other)?;
72 lines.push(b'\n');
73 storage.write(SESSIONS, &lines, Durability::Sync)
74}
75
76#[cfg_attr(not(test), allow(dead_code))]
79pub fn sessions(storage: &impl Storage) -> Vec<Session> {
80 let Ok(bytes) = storage.read(SESSIONS) else {
81 return Vec::new();
82 };
83 String::from_utf8_lossy(&bytes)
84 .lines()
85 .filter_map(|line| serde_json::from_str(line).ok())
86 .collect()
87}
88
89#[cfg_attr(not(test), allow(dead_code))]
96#[derive(Clone, PartialEq, Eq, Debug)]
97pub struct Record {
98 pub seq: u64,
99 pub meta: Option<Entry>,
100}
101
102pub fn paths(seq: u64) -> [String; 2] {
104 [payload_path(seq), sidecar_path(seq)]
105}
106
107fn payload_path(seq: u64) -> String {
108 format!("{DIR}/{seq:06}.kdl.gz")
109}
110
111fn sidecar_path(seq: u64) -> String {
112 format!("{DIR}/{seq:06}.json")
113}
114
115fn seq_of(name: &str) -> Option<u64> {
117 name.split('.').next()?.parse().ok()
118}
119
120pub fn next_seq(storage: &impl Storage) -> std::io::Result<u64> {
126 let highest = storage
127 .list(DIR)?
128 .iter()
129 .filter_map(|name| seq_of(name))
130 .max();
131 Ok(highest.map_or(0, |n| n + 1))
132}
133
134pub fn append(
137 storage: &impl Storage,
138 seq: u64,
139 meta: &Entry,
140 document: &str,
141) -> std::io::Result<()> {
142 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
143 encoder.write_all(document.as_bytes())?;
144 let payload = encoder.finish()?;
145 storage.write(&payload_path(seq), &payload, Durability::Relaxed)?;
146
147 let sidecar = serde_json::to_vec(meta).map_err(std::io::Error::other)?;
151 storage.write(&sidecar_path(seq), &sidecar, Durability::Relaxed)
152}
153
154#[cfg_attr(not(test), allow(dead_code))]
158pub fn records(storage: &impl Storage) -> std::io::Result<Vec<Record>> {
159 let names = storage.list(DIR)?;
160 let mut seqs: Vec<u64> = names
161 .iter()
162 .filter(|n| n.ends_with(".kdl.gz"))
163 .filter_map(|n| seq_of(n))
164 .collect();
165 seqs.sort_unstable();
166 Ok(seqs
167 .into_iter()
168 .map(|seq| Record {
169 seq,
170 meta: storage
171 .read(&sidecar_path(seq))
172 .ok()
173 .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
174 })
175 .collect())
176}
177
178#[cfg_attr(not(test), allow(dead_code))]
180pub fn read(storage: &impl Storage, seq: u64) -> std::io::Result<String> {
181 use std::io::Read as _;
182 let payload = storage.read(&payload_path(seq))?;
183 let mut out = String::new();
184 flate2::read::GzDecoder::new(&payload[..]).read_to_string(&mut out)?;
187 Ok(out)
188}
189
190#[cfg(not(target_arch = "wasm32"))]
192pub fn now_millis() -> u64 {
193 std::time::SystemTime::now()
194 .duration_since(std::time::UNIX_EPOCH)
195 .map_or(0, |d| d.as_millis() as u64)
196}
197
198#[cfg(target_arch = "wasm32")]
200pub fn now_millis() -> u64 {
201 js_sys::Date::now() as u64
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::storage::atomic::tests::TempDir;
208 use crate::storage::fs::FsStorage;
209
210 fn storage(name: &str) -> (TempDir, FsStorage) {
211 let dir = TempDir::new(name);
212 let s = FsStorage::new(dir.path());
213 (dir, s)
214 }
215
216 fn meta(changed: &[&str]) -> Entry {
217 Entry {
218 ts: 1_700_000_000_000,
219 command: Some("delete".to_string()),
220 changed: changed.iter().map(|s| (*s).to_string()).collect(),
221 }
222 }
223
224 #[test]
225 fn an_entry_round_trips() {
226 let (_dir, s) = storage("history-roundtrip");
227 append(&s, 0, &meta(&["b3"]), "top \"b0\"").unwrap();
228 assert_eq!(read(&s, 0).unwrap(), "top \"b0\"");
229 assert_eq!(
230 records(&s).unwrap(),
231 vec![Record {
232 seq: 0,
233 meta: Some(meta(&["b3"])),
234 }]
235 );
236 }
237
238 #[test]
239 fn a_container_with_no_history_reads_as_empty() {
240 let (_dir, s) = storage("history-empty");
241 assert_eq!(next_seq(&s).unwrap(), 0);
242 assert!(records(&s).unwrap().is_empty());
243 }
244
245 #[test]
248 fn numbering_resumes_from_what_is_on_disk() {
249 let (_dir, s) = storage("history-resume");
250 for seq in 0..3 {
251 append(&s, seq, &meta(&[]), "doc").unwrap();
252 }
253 assert_eq!(next_seq(&s).unwrap(), 3);
254
255 let reopened = FsStorage::new(s.root());
256 assert_eq!(next_seq(&reopened).unwrap(), 3);
257 }
258
259 #[test]
260 fn entries_come_back_oldest_first() {
261 let (_dir, s) = storage("history-order");
262 for seq in [2, 0, 1] {
263 append(&s, seq, &meta(&[]), "doc").unwrap();
264 }
265 let seqs: Vec<u64> = records(&s).unwrap().into_iter().map(|r| r.seq).collect();
266 assert_eq!(seqs, vec![0, 1, 2]);
267 }
268
269 #[test]
273 fn an_entry_without_its_sidecar_is_unlabelled_not_lost() {
274 let (dir, s) = storage("history-torn");
275 append(&s, 0, &meta(&["b3"]), "the document").unwrap();
276 std::fs::remove_file(dir.path().join(sidecar_path(0))).unwrap();
277
278 assert_eq!(
279 records(&s).unwrap(),
280 vec![Record { seq: 0, meta: None }],
281 "the entry vanished with its label"
282 );
283 assert_eq!(read(&s, 0).unwrap(), "the document");
284 }
285
286 #[test]
288 fn a_corrupt_sidecar_does_not_hide_the_other_entries() {
289 let (dir, s) = storage("history-bad-sidecar");
290 append(&s, 0, &meta(&["b1"]), "one").unwrap();
291 append(&s, 1, &meta(&["b2"]), "two").unwrap();
292 std::fs::write(dir.path().join(sidecar_path(0)), b"{ not json").unwrap();
293
294 let found = records(&s).unwrap();
295 assert_eq!(found.len(), 2);
296 assert!(found[0].meta.is_none());
297 assert_eq!(found[1].meta, Some(meta(&["b2"])));
298 }
299
300 #[test]
303 fn a_truncated_payload_is_an_error_not_half_a_document() {
304 let (dir, s) = storage("history-truncated");
305 append(
306 &s,
307 0,
308 &meta(&[]),
309 "top \"b0\"\nblock \"b0\" x=0 y=0 w=8 h=8",
310 )
311 .unwrap();
312 let path = dir.path().join(payload_path(0));
313 let whole = std::fs::read(&path).unwrap();
314 assert!(
317 whole.len() > 8,
318 "payload too small to truncate meaningfully"
319 );
320 std::fs::write(&path, &whole[..whole.len() - 4]).unwrap();
321
322 assert!(read(&s, 0).is_err());
323 assert_eq!(records(&s).unwrap().len(), 1);
325 }
326
327 #[test]
328 fn sessions_accumulate_across_openings() {
329 let (_dir, s) = storage("history-sessions");
330 assert!(sessions(&s).is_empty(), "nothing opened yet");
331
332 record_session(&s, &Session { ts: 1, seq: 0 }).unwrap();
333 record_session(&s, &Session { ts: 2, seq: 7 }).unwrap();
334 assert_eq!(
335 sessions(&s),
336 vec![Session { ts: 1, seq: 0 }, Session { ts: 2, seq: 7 }],
337 "a later opening must not replace the earlier ones"
338 );
339 }
340
341 #[test]
344 fn a_damaged_session_line_is_skipped() {
345 let (dir, s) = storage("history-sessions-damaged");
346 record_session(&s, &Session { ts: 1, seq: 0 }).unwrap();
347 record_session(&s, &Session { ts: 2, seq: 7 }).unwrap();
348
349 let path = dir.path().join("history/sessions.jsonl");
350 let good = std::fs::read_to_string(&path).unwrap();
351 let mut lines: Vec<&str> = good.lines().collect();
352 lines.insert(1, "{ not json");
353 std::fs::write(&path, lines.join("\n")).unwrap();
354
355 assert_eq!(
356 sessions(&s),
357 vec![Session { ts: 1, seq: 0 }, Session { ts: 2, seq: 7 }]
358 );
359 }
360
361 #[test]
364 fn the_clock_reads_milliseconds_not_seconds() {
365 let now = now_millis();
366 assert!(now > 1_577_836_800_000, "{now} predates 2020");
367 assert!(now < 4_102_444_800_000, "{now} postdates 2100");
368 }
369
370 #[test]
371 fn snapshots_are_deflated() {
372 let (dir, s) = storage("history-deflated");
373 let document = "top \"b0\"\n".repeat(500);
374 append(&s, 0, &meta(&[]), &document).unwrap();
375 let stored = std::fs::metadata(dir.path().join(payload_path(0)))
376 .unwrap()
377 .len();
378 assert!(
379 stored < document.len() as u64 / 4,
380 "{stored} bytes for a {} byte document",
381 document.len()
382 );
383 }
384}