Skip to main content

blockworx/store/
container.rs

1//! The `.bwx` container: a directory, per D1.
2//!
3//! ```text
4//! doc.bwx/
5//!   revs/           the whole document at every rev, compressed — THE document
6//!   manifest.jsonl  one appended row per rev: who, when, where, what it named
7//!   document.json   readable projection at head, self-contained
8//!   assets/         content-addressed artwork
9//!   lock            advisory single-writer lock
10//!   .gitattributes  how git must treat them
11//! ```
12//!
13//! A directory rather than a packed file because appends,
14//! content-addressed blobs, and per-writer sidecars all want to be
15//! separate files, and because git and text tools then handle a container
16//! natively. The single-file form is the share bundle, later.
17
18use std::fs::{File, OpenOptions};
19use std::io::Write as _;
20use std::path::{Path, PathBuf};
21
22use super::assets;
23use super::lock::{Claim, LockGuard, LockHolder};
24use super::manifest::{BreakReport, Row};
25use super::record::{Digest, WallTime};
26use super::revs::{self, REVS};
27
28use blockworx_doc::{document::Document, rev::Rev};
29
30pub const MANIFEST: &str = "manifest.jsonl";
31pub const PROJECTION: &str = "document.json";
32pub const ASSETS: &str = "assets";
33pub const LOCK: &str = "lock";
34pub const GITATTRIBUTES: &str = ".gitattributes";
35
36/// D1's template. `document.json` stays *diffable* — reading the change is
37/// the whole reason it exists — but never auto-merged, since it is
38/// generated and a merged projection would claim to be a fold nothing
39/// produced. Two divergent `manifest.jsonl`s are concurrent editing through the
40/// back door, which this design deliberately has no answer for, so the
41/// conflict is surfaced at merge time instead of waiting for a replay to
42/// refuse the result.
43const GITATTRIBUTES_TEMPLATE: &str = "\
44# A blockworx container. revs/ holds the document at every rev and
45# manifest.jsonl is the trail that names them; document.json is a
46# projection of the head, regenerated on save.
47#
48# The merge driver named here is not built in. Define it once, globally:
49#     git config --global merge.conflict-always.name 'never auto-merge'
50#     git config --global merge.conflict-always.driver false
51# Without it git falls back to the text merge, and a merged manifest will
52# be refused by the integrity chain at load — loudly, but later.
53manifest.jsonl merge=conflict-always
54document.json linguist-generated=true merge=binary
55revs/** binary
56assets/** binary
57lock export-ignore
58";
59
60/// Why a container is open without the right to write it. Every arm is
61/// something the user must be told, which is why this is not a bool.
62#[derive(Debug)]
63pub enum ReadOnlyReason {
64    /// Another live process holds the advisory lock.
65    Locked(LockHolder),
66    /// Opened to be read rather than edited — someone else's document being
67    /// looked at. The lock was never claimed, which is the
68    /// whole point: a reader must not stand between an owner and their own
69    /// document.
70    Reading,
71    /// The manifest does not verify; only the prefix before the break is
72    /// real.
73    HistoryBroken(BreakReport),
74    /// A row could not be appended, so this session's document and the
75    /// files have parted ways. Nothing more is written until the container
76    /// is reopened.
77    WriteFailed(std::io::Error),
78}
79
80impl std::fmt::Display for ReadOnlyReason {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        match self {
83            ReadOnlyReason::Locked(held) => {
84                write!(f, "another blockworx ({held}) has this diagram open")
85            }
86            ReadOnlyReason::Reading => write!(f, "this container was opened to be read"),
87            ReadOnlyReason::HistoryBroken(report) => {
88                write!(f, "the manifest does not verify at {report}")
89            }
90            ReadOnlyReason::WriteFailed(error) => {
91                write!(f, "a row could not be appended: {error}")
92            }
93        }
94    }
95}
96
97/// Whether this handle may write, and the manifest it writes through.
98pub enum Access {
99    Writable(Writer),
100    ReadOnly(ReadOnlyReason),
101}
102
103pub struct Writer {
104    manifest: File,
105    /// Released when the container is dropped, and moved with the container
106    /// when it is renamed.
107    lock: LockGuard,
108}
109
110/// Why a container could not be opened at all — as opposed to opened
111/// read-only, which is an outcome rather than a failure.
112#[derive(Debug, thiserror::Error)]
113pub enum ContainerError {
114    #[error("{0} already holds a diagram")]
115    Exists(PathBuf),
116    #[error("{0} is not a blockworx diagram: it has no {MANIFEST}")]
117    NotAContainer(PathBuf),
118    #[error(transparent)]
119    Io(#[from] std::io::Error),
120}
121
122pub struct Container {
123    root: PathBuf,
124    access: Access,
125}
126
127impl Container {
128    /// Lay out a new container and take its lock.
129    ///
130    /// # Errors
131    /// [`ContainerError::Exists`] if `root` already holds a manifest, or the
132    /// underlying I/O failure.
133    pub fn create(root: &Path, now: WallTime) -> Result<Self, ContainerError> {
134        Self::create_holding(root, now, b"")
135    }
136
137    /// The same, with `rows` already in it — the prefix Save-as, which
138    /// copies an existing manifest's lines rather than re-writing them.
139    /// The bytes go down before the lock is taken, so nothing can append
140    /// into the middle of them.
141    ///
142    /// # Errors
143    /// As [`Self::create`].
144    pub fn create_holding(root: &Path, now: WallTime, rows: &[u8]) -> Result<Self, ContainerError> {
145        if root.join(MANIFEST).exists() {
146            return Err(ContainerError::Exists(root.to_path_buf()));
147        }
148        std::fs::create_dir_all(root.join(ASSETS))?;
149        std::fs::create_dir_all(root.join(REVS))?;
150        std::fs::write(root.join(GITATTRIBUTES), GITATTRIBUTES_TEMPLATE)?;
151        let mut file = File::create(root.join(MANIFEST))?;
152        file.write_all(rows)?;
153        file.sync_all()?;
154        // The entries themselves, not just their contents: a power cut
155        // between here and the first append must not leave a directory
156        // that has a manifest in it only sometimes.
157        sync_dir(root)?;
158        Self::attach(root, now)
159    }
160
161    /// Open an existing container, taking its lock if nobody holds it.
162    ///
163    /// # Errors
164    /// [`ContainerError::NotAContainer`] when there is no manifest to open, or
165    /// the underlying I/O failure.
166    pub fn open(root: &Path, now: WallTime) -> Result<Self, ContainerError> {
167        Self::require_manifest(root)?;
168        Self::attach(root, now)
169    }
170
171    /// Open an existing container to be read and never written, leaving the
172    /// lock untouched.
173    ///
174    /// The honest mode for a reader: claiming the lock merely to look
175    /// would either demote the session editing that container or hold a
176    /// lock against one nobody is writing, and a handle that will refuse
177    /// every write anyway has no business taking either.
178    ///
179    /// # Errors
180    /// As [`Self::open`].
181    pub fn reading(root: &Path) -> Result<Self, ContainerError> {
182        Self::require_manifest(root)?;
183        Ok(Self {
184            root: root.to_path_buf(),
185            access: Access::ReadOnly(ReadOnlyReason::Reading),
186        })
187    }
188
189    fn require_manifest(root: &Path) -> Result<(), ContainerError> {
190        if root.join(MANIFEST).is_file() {
191            Ok(())
192        } else {
193            Err(ContainerError::NotAContainer(root.to_path_buf()))
194        }
195    }
196
197    fn attach(root: &Path, now: WallTime) -> Result<Self, ContainerError> {
198        let access = match super::lock::claim(root, now)? {
199            Claim::Held(held) => Access::ReadOnly(ReadOnlyReason::Locked(held)),
200            Claim::Taken(lock) => Access::Writable(Writer {
201                manifest: OpenOptions::new().append(true).open(root.join(MANIFEST))?,
202                lock,
203            }),
204        };
205        Ok(Self {
206            root: root.to_path_buf(),
207            access,
208        })
209    }
210
211    pub fn root(&self) -> &Path {
212        &self.root
213    }
214
215    pub fn access(&self) -> &Access {
216        &self.access
217    }
218
219    /// Rename this container's directory — which renames the document,
220    /// since the directory's name is what the document is called (D20).
221    ///
222    /// The session carries on through it: the open manifest is held by a
223    /// descriptor rather than by its path, and the lock file moves with the
224    /// directory it is inside, so an edit made after the rename lands in
225    /// the same manifest the edits before it did.
226    ///
227    /// # Errors
228    /// [`std::io::ErrorKind::AlreadyExists`] when something already stands
229    /// at `to`, or the rename's own failure.
230    pub fn rename(&mut self, to: &Path) -> std::io::Result<()> {
231        if to.exists() {
232            return Err(std::io::Error::new(
233                std::io::ErrorKind::AlreadyExists,
234                format!("{} already exists", to.display()),
235            ));
236        }
237        std::fs::rename(&self.root, to)?;
238        // No directory fsync, unlike `create`: a rename a power cut takes
239        // back leaves a whole container under its old name, where `create`
240        // interrupted would leave half of one.
241        self.root = to.to_path_buf();
242        if let Access::Writable(writer) = &mut self.access {
243            writer.lock.follow(to);
244        }
245        Ok(())
246    }
247
248    /// Give up the right to write, releasing the lock with it. Called when
249    /// the manifest turns out not to verify, and when an append fails.
250    pub fn demote(&mut self, reason: ReadOnlyReason) {
251        self.access = Access::ReadOnly(reason);
252    }
253
254    /// # Errors
255    /// The underlying read failure.
256    pub fn read_manifest(&self) -> std::io::Result<String> {
257        std::fs::read_to_string(self.root.join(MANIFEST))
258    }
259
260    /// Where this container's artwork is read and written.
261    pub fn assets(&self) -> assets::Dir {
262        assets::Dir::at(&self.root)
263    }
264
265    /// Where this container keeps the document at every rev.
266    pub fn revs(&self) -> revs::Dir {
267        revs::Dir::at(&self.root)
268    }
269
270    /// Write the document as it stands at `rev` into `revs/`, its payloads
271    /// into `assets/`, and hand back the digest of the rev's bytes.
272    ///
273    /// Called before the row that names the rev is appended, so the
274    /// manifest never names bytes a crash could still take away — and the
275    /// payloads land before the rev that references them, for the same
276    /// reason one level down.
277    ///
278    /// # Errors
279    /// [`WriteRefusal::ReadOnly`] if this handle does not hold the lock,
280    /// or the write that did not land.
281    pub fn write_rev(&mut self, rev: Rev, document: &Document) -> Result<Digest, WriteRefusal> {
282        let Access::Writable(_) = &self.access else {
283            return Err(WriteRefusal::ReadOnly);
284        };
285        Ok(revs::write(
286            &mut self.revs(),
287            rev,
288            document,
289            &mut self.assets(),
290        )?)
291    }
292
293    /// The document this container holds at `rev`, payloads and all.
294    ///
295    /// # Errors
296    /// [`revs::RevFault`]: no file, one that is not a document this build
297    /// reads, or artwork the container cannot hand back.
298    pub fn read_rev(&self, rev: Rev) -> Result<Document, revs::RevFault> {
299        let document = revs::read(&self.revs(), rev)?;
300        Ok(revs::attached(document, &self.assets())?)
301    }
302
303    /// Append one row and make it durable before returning: the line and
304    /// its newline in one write, then an fsync. A crash anywhere in here
305    /// loses at most this row, and loses it *visibly* — the manifest then
306    /// ends without a newline, which is how load knows to drop it.
307    ///
308    /// # Errors
309    /// [`WriteRefusal::ReadOnly`] if this handle does not hold the lock,
310    /// or the underlying write failure.
311    pub fn append(&mut self, row: &Row) -> Result<(), WriteRefusal> {
312        let Access::Writable(writer) = &mut self.access else {
313            return Err(WriteRefusal::ReadOnly);
314        };
315        let mut line = row.canonical_bytes();
316        line.push(b'\n');
317        writer.manifest.write_all(&line)?;
318        writer.manifest.sync_data()?;
319        Ok(())
320    }
321
322    /// Cut a partial trailing row off, so the next append does not splice
323    /// itself onto half a line.
324    ///
325    /// # Errors
326    /// As [`Self::append`].
327    pub fn truncate_manifest(&mut self, bytes: u64) -> Result<(), WriteRefusal> {
328        let Access::Writable(writer) = &mut self.access else {
329            return Err(WriteRefusal::ReadOnly);
330        };
331        writer.manifest.set_len(bytes)?;
332        writer.manifest.sync_all()?;
333        Ok(())
334    }
335}
336
337#[derive(Debug, thiserror::Error)]
338pub enum WriteRefusal {
339    #[error("this diagram is open read-only")]
340    ReadOnly,
341    #[error(transparent)]
342    Io(#[from] std::io::Error),
343}
344
345/// What became of a container offered for [`discard_pristine`].
346#[derive(Clone, Copy, PartialEq, Eq, Debug)]
347pub enum Discarded {
348    Removed,
349    /// Something is in it, so it stays: a row in the manifest, artwork, or a
350    /// file this layout never writes.
351    Kept,
352}
353
354/// The files [`Container::create`] lays down. Anything else in a container
355/// belongs to whoever put it there.
356const LAID_DOWN: [&str; 4] = [MANIFEST, PROJECTION, LOCK, GITATTRIBUTES];
357
358/// The directories it lays down. Each must be empty for the container to
359/// count as pristine.
360const LAID_DOWN_DIRS: [&str; 2] = [ASSETS, REVS];
361
362/// Remove a container nothing was ever written into — D20's cleanup, so a
363/// launch and a quit leave the documents directory as they found it.
364///
365/// Deliberately timid: an empty manifest is necessary but not sufficient, and
366/// only a directory holding exactly what [`Container::create`] laid down is
367/// removed. Anything else — a row, an asset, a file the user put beside
368/// the manifest — and the container stays, because deleting a directory we did
369/// not write every part of is not ours to decide. The caller narrows this
370/// further: only a container *this session created* is ever offered.
371///
372/// # Errors
373/// The read or the removal that did not work.
374pub fn discard_pristine(root: &Path) -> std::io::Result<Discarded> {
375    if std::fs::metadata(root.join(MANIFEST))?.len() != 0 {
376        return Ok(Discarded::Kept);
377    }
378    let mut files = Vec::new();
379    let mut dirs = Vec::new();
380    for entry in std::fs::read_dir(root)? {
381        let entry = entry?;
382        let name = entry.file_name();
383        let named = |ours: &[&str]| name.to_str().is_some_and(|name| ours.contains(&name));
384        let kind = entry.file_type()?;
385        if kind.is_dir() && named(&LAID_DOWN_DIRS) {
386            if std::fs::read_dir(entry.path())?.next().is_some() {
387                return Ok(Discarded::Kept);
388            }
389            dirs.push(entry.path());
390        } else if kind.is_file() && named(&LAID_DOWN) {
391            files.push(entry.path());
392        } else {
393            return Ok(Discarded::Kept);
394        }
395    }
396    for file in files {
397        std::fs::remove_file(file)?;
398    }
399    for dir in dirs {
400        std::fs::remove_dir(dir)?;
401    }
402    std::fs::remove_dir(root)?;
403    Ok(Discarded::Removed)
404}
405
406/// Flush the directory entry itself, so a freshly laid-out container
407/// survives a power cut and not just a process crash.
408#[cfg(unix)]
409fn sync_dir(dir: &Path) -> std::io::Result<()> {
410    File::open(dir)?.sync_all()
411}
412
413/// Windows has no directory handle to sync.
414#[cfg(not(unix))]
415fn sync_dir(_dir: &Path) -> std::io::Result<()> {
416    Ok(())
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::store::tests::fixture;
423
424    #[test]
425    fn a_created_container_holds_the_whole_template() {
426        let dir = fixture::dir("container-layout");
427        let root = dir.join("doc.bwx");
428        let container =
429            Container::create(&root, WallTime::EPOCH).expect("the container is laid out");
430
431        assert!(matches!(container.access(), Access::Writable(_)));
432        assert_eq!(container.read_manifest().expect("an empty manifest"), "");
433        assert!(root.join(ASSETS).is_dir());
434        assert!(root.join(REVS).is_dir(), "a new container has no revs/");
435        assert!(
436            root.join(LOCK).is_file(),
437            "a writable handle holds the lock"
438        );
439        let attributes =
440            std::fs::read_to_string(root.join(GITATTRIBUTES)).expect("the git template");
441        assert!(attributes.contains("manifest.jsonl merge=conflict-always"));
442        assert!(attributes.contains("document.json linguist-generated=true"));
443        assert!(attributes.contains("revs/** binary"));
444    }
445
446    #[test]
447    fn creating_over_an_existing_container_is_refused() {
448        let dir = fixture::dir("container-exists");
449        let root = dir.join("doc.bwx");
450        let first = Container::create(&root, WallTime::EPOCH).expect("the first is laid out");
451        drop(first);
452
453        assert!(matches!(
454            Container::create(&root, WallTime::EPOCH),
455            Err(ContainerError::Exists(_)),
456        ));
457        assert!(matches!(
458            Container::open(&dir.join("nothing-here"), WallTime::EPOCH),
459            Err(ContainerError::NotAContainer(_)),
460        ));
461    }
462
463    /// The lock is the writable handle's lifetime, so closing a container
464    /// hands the next one the right to write.
465    #[test]
466    fn dropping_a_container_releases_its_lock() {
467        let dir = fixture::dir("container-lock-lifetime");
468        let root = dir.join("doc.bwx");
469        let container = Container::create(&root, WallTime::EPOCH).expect("the container");
470        assert!(matches!(
471            Container::open(&root, WallTime::EPOCH)
472                .expect("a second handle opens")
473                .access(),
474            Access::ReadOnly(ReadOnlyReason::Locked(_)),
475        ));
476
477        drop(container);
478        assert!(matches!(
479            Container::open(&root, WallTime::EPOCH)
480                .expect("a third handle opens")
481                .access(),
482            Access::Writable(_),
483        ));
484    }
485
486    /// The property renaming a document rests on: the session keeps the
487    /// container through the move. The manifest's descriptor follows the
488    /// inode rather than the path, so an append after the rename lands in
489    /// the renamed file — and the lock, which is a *path*, has to be told.
490    #[test]
491    fn a_renamed_container_keeps_its_lock_and_its_open_manifest() {
492        let dir = fixture::dir("container-rename");
493        let root = dir.join("doc.bwx");
494        let renamed = dir.join("engine.bwx");
495        let mut container = Container::create(&root, WallTime::EPOCH).expect("the container");
496        let row = fixture::rows(1).pop().expect("one row");
497        container.append(&row).expect("the first row lands");
498
499        container.rename(&renamed).expect("the container moves");
500
501        assert_eq!(container.root(), renamed);
502        assert!(!root.exists(), "the old name still stands");
503        assert!(
504            renamed.join(LOCK).is_file(),
505            "the lock did not travel with the container",
506        );
507        container.append(&row).expect("an append after the rename");
508        assert_eq!(
509            std::fs::read_to_string(renamed.join(MANIFEST))
510                .expect("the renamed manifest")
511                .lines()
512                .count(),
513            2,
514            "the session kept appending to the file it had open",
515        );
516        assert!(
517            matches!(
518                Container::open(&renamed, WallTime::EPOCH)
519                    .expect("a second handle")
520                    .access(),
521                Access::ReadOnly(ReadOnlyReason::Locked(_)),
522            ),
523            "the lock was dropped somewhere in the move",
524        );
525
526        drop(container);
527        assert!(
528            !renamed.join(LOCK).exists(),
529            "releasing the lock removed the wrong path",
530        );
531    }
532
533    #[test]
534    fn a_rename_onto_something_that_exists_is_refused() {
535        let dir = fixture::dir("container-rename-onto");
536        let root = dir.join("doc.bwx");
537        let taken = dir.join("taken.bwx");
538        let mut container = Container::create(&root, WallTime::EPOCH).expect("the container");
539        Container::create(&taken, WallTime::EPOCH).expect("the container in the way");
540
541        let refusal = container
542            .rename(&taken)
543            .expect_err("renaming over a container must be refused");
544        assert_eq!(refusal.kind(), std::io::ErrorKind::AlreadyExists);
545        assert_eq!(container.root(), root, "the store moved anyway");
546        assert!(
547            taken.join(MANIFEST).is_file(),
548            "the container in the way is gone"
549        );
550    }
551
552    /// The cleanup D20 asks for, and everything it refuses to do: only a
553    /// container holding exactly what `create` laid down, with an empty log,
554    /// is removed.
555    #[test]
556    fn only_a_container_with_nothing_in_it_is_discarded() {
557        let dir = fixture::dir("container-discard");
558
559        let empty = dir.join("empty.bwx");
560        drop(Container::create(&empty, WallTime::EPOCH).expect("the empty container"));
561        assert_eq!(
562            discard_pristine(&empty).expect("the sweep answers"),
563            Discarded::Removed,
564        );
565        assert!(!empty.exists(), "a pristine container was left behind");
566
567        let written = dir.join("written.bwx");
568        let mut container = Container::create(&written, WallTime::EPOCH).expect("the container");
569        let row = fixture::rows(1).pop().expect("one row");
570        container.append(&row).expect("the row lands");
571        drop(container);
572        assert_eq!(
573            discard_pristine(&written).expect("the sweep answers"),
574            Discarded::Kept,
575            "a container with a row in it was removed",
576        );
577        assert!(written.join(MANIFEST).is_file());
578
579        let theirs = dir.join("theirs.bwx");
580        drop(Container::create(&theirs, WallTime::EPOCH).expect("the container"));
581        std::fs::write(theirs.join("notes.txt"), b"mine").expect("a file of the user's own");
582        assert_eq!(
583            discard_pristine(&theirs).expect("the sweep answers"),
584            Discarded::Kept,
585            "a directory holding something we did not write was removed",
586        );
587        assert!(theirs.join(MANIFEST).is_file(), "and it was half-removed");
588    }
589
590    #[test]
591    fn a_read_only_handle_refuses_to_write() {
592        let dir = fixture::dir("container-read-only");
593        let root = dir.join("doc.bwx");
594        let _held = Container::create(&root, WallTime::EPOCH).expect("the container");
595        let mut second = Container::open(&root, WallTime::EPOCH).expect("a second handle");
596
597        let row = fixture::rows(1).pop().expect("one row");
598        assert!(matches!(second.append(&row), Err(WriteRefusal::ReadOnly)));
599        assert!(matches!(
600            second.truncate_manifest(0),
601            Err(WriteRefusal::ReadOnly)
602        ));
603    }
604}