1use blockworx_doc::{document::Document, rev::Rev};
23
24use super::assets::{AssetFault, AssetSource, hydrate};
25use super::record::Digest;
26
27pub const REVS: &str = "revs";
29
30pub trait Backing {
33 fn put(&mut self, at: Rev, bytes: &[u8]) -> std::io::Result<()>;
36
37 fn get(&self, at: Rev) -> std::io::Result<Option<Vec<u8>>>;
43
44 fn names(&self, at: Rev) -> String;
46}
47
48#[derive(Default, Debug)]
55pub struct Memory(std::collections::BTreeMap<Rev, Vec<u8>>);
56
57impl Backing for Memory {
58 fn put(&mut self, at: Rev, bytes: &[u8]) -> std::io::Result<()> {
59 self.0.insert(at, bytes.to_vec());
60 Ok(())
61 }
62
63 fn get(&self, at: Rev) -> std::io::Result<Option<Vec<u8>>> {
64 Ok(self.0.get(&at).cloned())
65 }
66
67 fn names(&self, at: Rev) -> String {
68 format!("rev {} of this session", at.get())
69 }
70}
71
72#[derive(Debug, thiserror::Error)]
75pub enum RevFault {
76 #[error("rev {} was never written to {where_it_would_be}", .at.get())]
77 Missing { at: Rev, where_it_would_be: String },
78 #[error("rev {}'s copy could not be read: {why}", .at.get())]
79 Unreadable { at: Rev, why: std::io::Error },
80 #[error("rev {}'s copy is not a document this build reads: {why}", .at.get())]
81 NotADocument { at: Rev, why: serde_json::Error },
82 #[error("rev {}'s copy holds bytes that hash to {found}, not {named}", .at.get())]
83 Tampered {
84 at: Rev,
85 named: Digest,
86 found: Digest,
87 },
88 #[error(transparent)]
89 Artwork(#[from] AssetFault),
90 #[error("rev {} could not be written: {why}", .at.get())]
91 NotWritten { at: Rev, why: std::io::Error },
92}
93
94pub fn write(
106 into: &mut dyn Backing,
107 at: Rev,
108 document: &Document,
109 payloads: &mut dyn super::assets::AssetSink,
110) -> std::io::Result<Digest> {
111 for (hash, asset) in document.assets() {
112 payloads.put(hash, asset)?;
113 }
114 let bytes = encode(&document.without_assets())?;
115 into.put(at, &bytes)?;
116 Ok(Digest::of(&bytes))
117}
118
119pub fn read(from: &dyn Backing, at: Rev) -> Result<Document, RevFault> {
128 if at == Rev::ZERO {
129 return Ok(Document::default());
130 }
131 let bytes = bytes(from, at)?;
132 let json = unpack(&bytes).map_err(|why| RevFault::Unreadable { at, why })?;
133 let mut document: Document =
134 serde_json::from_slice(&json).map_err(|why| RevFault::NotADocument { at, why })?;
135 document.positioned_at(at);
136 Ok(document)
137}
138
139pub fn attached(mut document: Document, assets: &dyn AssetSource) -> Result<Document, AssetFault> {
146 document.attach_assets(|hash| hydrate(assets, hash))?;
147 Ok(document)
148}
149
150pub fn stamp(from: &dyn Backing, at: Rev) -> Result<Digest, RevFault> {
156 if at == Rev::ZERO {
159 return Ok(Digest::of(&[]));
160 }
161 Ok(Digest::of(&bytes(from, at)?))
162}
163
164pub fn bytes(from: &dyn Backing, at: Rev) -> Result<Vec<u8>, RevFault> {
170 from.get(at)
171 .map_err(|why| RevFault::Unreadable { at, why })?
172 .ok_or_else(|| RevFault::Missing {
173 at,
174 where_it_would_be: from.names(at),
175 })
176}
177
178pub fn witnessed(from: &dyn Backing, at: Rev, named: Digest) -> Result<(), RevFault> {
185 let found = stamp(from, at)?;
186 if found == named {
187 Ok(())
188 } else {
189 Err(RevFault::Tampered { at, named, found })
190 }
191}
192
193pub fn through(at: Rev) -> impl Iterator<Item = Rev> {
196 std::iter::successors(Some(Rev::ZERO), |rev| Some(rev.next()))
197 .skip(1)
198 .take_while(move |rev| *rev <= at)
199}
200
201fn encode(document: &Document) -> std::io::Result<Vec<u8>> {
204 let json = serde_json::to_vec(document).map_err(std::io::Error::other)?;
205 pack(&json)
206}
207
208#[cfg(not(target_arch = "wasm32"))]
211const LEVEL: i32 = 1;
212
213#[cfg(not(target_arch = "wasm32"))]
214fn pack(json: &[u8]) -> std::io::Result<Vec<u8>> {
215 zstd::encode_all(json, LEVEL)
216}
217
218#[cfg(not(target_arch = "wasm32"))]
219fn unpack(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
220 zstd::decode_all(bytes)
221}
222
223#[cfg(target_arch = "wasm32")]
227#[expect(clippy::unnecessary_wraps, reason = "the native pair can fail")]
228fn pack(json: &[u8]) -> std::io::Result<Vec<u8>> {
229 Ok(json.to_vec())
230}
231
232#[cfg(target_arch = "wasm32")]
233#[expect(clippy::unnecessary_wraps, reason = "the native pair can fail")]
234fn unpack(bytes: &[u8]) -> std::io::Result<Vec<u8>> {
235 Ok(bytes.to_vec())
236}
237
238#[cfg(not(target_arch = "wasm32"))]
239pub use native::{Dir, path};
240
241#[cfg(not(target_arch = "wasm32"))]
242mod native {
243 use super::{Backing, REVS, Rev};
244 use std::path::{Path, PathBuf};
245
246 pub fn path(root: &Path, at: Rev) -> PathBuf {
249 root.join(REVS).join(format!("{:06}.json.zst", at.get()))
250 }
251
252 pub struct Dir(PathBuf);
254
255 impl Dir {
256 pub fn at(root: &Path) -> Self {
257 Self(root.to_path_buf())
258 }
259 }
260
261 impl Backing for Dir {
262 fn put(&mut self, at: Rev, bytes: &[u8]) -> std::io::Result<()> {
263 std::fs::create_dir_all(self.0.join(REVS))?;
267 crate::atomic::write_atomically(&path(&self.0, at), bytes)
268 }
269
270 fn get(&self, at: Rev) -> std::io::Result<Option<Vec<u8>>> {
271 match std::fs::read(path(&self.0, at)) {
272 Ok(bytes) => Ok(Some(bytes)),
273 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
274 Err(error) => Err(error),
275 }
276 }
277
278 fn names(&self, at: Rev) -> String {
279 path(&self.0, at).display().to_string()
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::store::assets::Held;
288 use crate::store::tests::fixture;
289 use blockworx_doc::fixtures::rev;
290
291 #[test]
294 fn a_rev_round_trips_and_stamps_the_bytes_it_was_written_as() {
295 let document = fixture::documents(2).pop().expect("two documents");
296 let mut backing = Memory::default();
297 let mut payloads = Held::default();
298 let stamped = write(&mut backing, rev(1), &document, &mut payloads).expect("the rev lands");
299
300 assert_eq!(
301 stamp(&backing, rev(1)).expect("the bytes are there"),
302 stamped
303 );
304 assert_eq!(read(&backing, rev(1)).expect("it reads back"), document);
305 witnessed(&backing, rev(1), stamped).expect("the rev is what it says");
306 }
307
308 #[test]
310 fn rev_zero_is_the_empty_document_and_holds_no_bytes() {
311 let backing = Memory::default();
312 assert_eq!(
313 read(&backing, Rev::ZERO).expect("the empty document"),
314 Document::default(),
315 );
316 assert!(matches!(
317 read(&backing, rev(4)),
318 Err(RevFault::Missing { .. }),
319 ));
320 }
321
322 #[test]
325 fn a_rev_file_holds_no_payloads_and_reads_back_the_ones_it_references() {
326 let asset = fixture::svg(1);
327 let document = fixture::with_icon(&asset);
328 assert_eq!(document.assets().count(), 1, "precondition: it holds one");
329
330 let mut backing = Memory::default();
331 let mut payloads = Held::default();
332 write(&mut backing, rev(1), &document, &mut payloads).expect("the rev lands");
333
334 let stripped = read(&backing, rev(1)).expect("it reads back");
335 assert_eq!(stripped.assets().count(), 0, "the rev carries bytes");
336 assert_eq!(
337 attached(stripped, &payloads).expect("the payload is in the store"),
338 document,
339 );
340 }
341
342 #[test]
345 fn bytes_that_are_not_what_the_row_names_are_refused() {
346 let document = fixture::documents(1).pop().expect("one document");
347 let mut backing = Memory::default();
348 let mut payloads = Held::default();
349 let stamped = write(&mut backing, rev(1), &document, &mut payloads).expect("the rev lands");
350
351 backing
352 .put(rev(1), b"not a document")
353 .expect("it is rewritten");
354 assert!(matches!(
355 witnessed(&backing, rev(1), stamped),
356 Err(RevFault::Tampered { .. }),
357 ));
358 assert!(matches!(
359 read(&backing, rev(1)),
360 Err(RevFault::Unreadable { .. } | RevFault::NotADocument { .. }),
361 ));
362 }
363
364 #[test]
365 fn the_revs_through_a_head_start_at_one() {
366 assert_eq!(
367 through(rev(3)).collect::<Vec<_>>(),
368 [rev(1), rev(2), rev(3)]
369 );
370 assert_eq!(through(Rev::ZERO).count(), 0);
371 }
372}