Skip to main content

blockworx/store/
handle.rs

1//! The store: a container and the [`Repo`] it holds, joined so that every
2//! accepted commit reaches the files before the call returns.
3//!
4//! This is the only door. A caller can read the repo but never write it,
5//! so "folded but not written" — the state a crash would silently keep —
6//! is not something an outside caller can produce.
7
8use std::path::Path;
9use std::time::Duration;
10
11use blockworx_doc::{
12    commit::Commit,
13    document::Document,
14    id::EntityRef,
15    repo::Repo,
16    rev::Rev,
17    trail::{Direction, Trail},
18};
19
20use super::Refusal;
21use super::container::{Access, Container, ContainerError, ReadOnlyReason, WriteRefusal};
22use super::manifest::{self, End, Fault, History, Row, RowKind, Tail};
23use super::projection::{self, Found, Freshness, Stamp};
24use super::record::{Attribution, Digest, Identity, WallTime};
25use super::revs;
26use super::tags::{Tagging, Tags};
27
28/// Where a row's wall time comes from. The system clock is the only
29/// production value; the pinned arm is the seam a byte-stable golden and
30/// a repeatable test need, and it advances so a fixture's rows are
31/// ordered in time without depending on one.
32#[derive(Clone, Copy, Debug)]
33pub enum Clock {
34    System,
35    Pinned { at: WallTime, step: Duration },
36}
37
38impl Clock {
39    pub(super) fn tick(&mut self) -> WallTime {
40        match self {
41            Clock::System => super::history::now(),
42            Clock::Pinned { at, step } => {
43                let now = *at;
44                *at = WallTime::from_unix_millis(now.unix_millis() + step.as_millis() as u64);
45                now
46            }
47        }
48    }
49}
50
51/// Why a container could not be seeded from a session's commits — the
52/// Save-As-container path, which lays out a container and then replays a
53/// scratch session's commits into it through the ordinary write door.
54#[derive(Debug, thiserror::Error)]
55pub enum SeedFailure {
56    #[error(transparent)]
57    Create(#[from] ContainerError),
58    #[error("seeding stopped at commit {at}: {why}")]
59    Append { at: usize, why: Refusal },
60}
61
62/// What a history step is, before the repo has taken it.
63enum Step {
64    Edit(Commit),
65    /// A commit that is already the document's past rather than a step this
66    /// session took — the seeding path. Written like an edit, trailed like
67    /// nothing: no one may take back a step they did not make.
68    Seed(Commit),
69    Undo(Rev),
70    Redo(Rev),
71}
72
73pub struct Store {
74    container: Container,
75    repo: Repo,
76    /// Where this session's history stands. Beside the repo rather than
77    /// in it: a step adopts the rev copy this container holds, which is
78    /// something only the store can read (`docs/log-vs-snapshot.md` S6).
79    trail: Trail,
80    clock: Clock,
81    /// The digest the next row must name as its parent.
82    head: Digest,
83    /// Every row this container's manifest holds that spends a rev, in
84    /// rev order: `rows()[i]` describes rev `i + 1`. What the history
85    /// panel, the rev pick and `blockworx log` all read.
86    rows: Vec<Row>,
87    /// What the manifest's tag rows name its revs. Rebuilt at every open
88    /// and never handed to the repo — D18's whole point.
89    tags: Tags,
90    tail: Tail,
91    /// What `document.json` was last seen holding — read once at open and
92    /// updated on every save, so the staleness the chrome shows costs no
93    /// file access per frame.
94    projected: Found,
95}
96
97impl Store {
98    /// Lay out a new container and open it at the empty document.
99    ///
100    /// # Errors
101    /// As [`Container::create`].
102    pub fn create(root: &Path, mut clock: Clock) -> Result<Self, ContainerError> {
103        let container = Container::create(root, clock.tick())?;
104        Ok(Self {
105            container,
106            repo: Repo::default(),
107            trail: Trail::default(),
108            clock,
109            head: Digest::genesis(),
110            rows: Vec::new(),
111            tags: Tags::default(),
112            tail: Tail::Whole,
113            projected: Found::Nothing,
114        })
115    }
116
117    /// Lay out a new container and put `commits` into it, each as one row
118    /// and one rev of its own.
119    ///
120    /// This is how a *scratch* session becomes durable: each commit lands
121    /// as its own rev, so the container's history *is* the session's
122    /// history. Nothing is trailed — the commits are the new container's
123    /// past.
124    ///
125    /// A session that already has a container does **not** come this way:
126    /// commits are all this can carry, so the wall times, the authors, the
127    /// edit/undo/redo kinds and the tags would be flattened into a run of
128    /// fresh edits by whoever pressed Save-as. That path copies the
129    /// manifest's own rows instead ([`super::prefix::save_through`]).
130    ///
131    /// # Errors
132    /// [`SeedFailure::Create`] as [`Self::create`], and
133    /// [`SeedFailure::Append`] naming the commit that did not land — which
134    /// leaves a container holding the prefix that did.
135    pub fn seeded(
136        root: &Path,
137        clock: Clock,
138        commits: &[Commit],
139        author: &Identity,
140    ) -> Result<Self, SeedFailure> {
141        let mut store = Self::create(root, clock)?;
142        for (at, commit) in commits.iter().enumerate() {
143            store
144                .write(Step::Seed(commit.clone()), author.into())
145                .map_err(|why| SeedFailure::Append { at, why })?;
146        }
147        Ok(store)
148    }
149
150    /// Open a container: read the manifest, verify its chain, and read the
151    /// head rev.
152    ///
153    /// A manifest that does not verify is not a failure to open: the
154    /// container opens read-only at the last good prefix, with the break as
155    /// its [`ReadOnlyReason`], because the reader is owed a look at the
156    /// past that *is* intact. A manifest with a partial trailing row opens
157    /// writable, the row dropped and the file cut back to the last whole
158    /// line.
159    ///
160    /// # Errors
161    /// As [`Container::open`] — there is no container, or it cannot be
162    /// read.
163    pub fn open(root: &Path, mut clock: Clock) -> Result<Self, ContainerError> {
164        Self::over(Container::open(root, clock.tick())?, clock)
165    }
166
167    /// Open a container to be read and never written.
168    ///
169    /// The same read as [`Self::open`] over a handle that never claimed
170    /// the lock ([`Container::reading`]), so reading someone's document —
171    /// or a fixture out of `fixtures/` — cannot cost its owner the right
172    /// to write it. Every write door refuses with [`Refusal::ReadOnly`],
173    /// which is why this takes no [`Clock`]: nothing here mints a row.
174    ///
175    /// # Errors
176    /// As [`Self::open`].
177    pub fn reading(root: &Path) -> Result<Self, ContainerError> {
178        Self::over(Container::reading(root)?, Clock::System)
179    }
180
181    /// Read an already-attached container — [`Self::open`]'s second half,
182    /// shared with the prefix Save-as, which lays a container out with a
183    /// manifest already in it.
184    ///
185    /// # Errors
186    /// The manifest that could not be read.
187    pub(super) fn over(mut container: Container, clock: Clock) -> Result<Self, ContainerError> {
188        let text = container.read_manifest()?;
189        let scanned = manifest::scan(&text);
190        // The head is the document this session opens on, so its bytes are
191        // checked here — the whole of what replaced D12's per-record
192        // stamp, and one blake3 over one file. Every *other* rev is
193        // `blockworx verify`'s business.
194        let (rows, broken) = whole(&container, scanned.rows);
195        let history = manifest::history(&rows);
196        let head = match container.read_rev(history.rev()) {
197            Ok(document) => document,
198            Err(fault) => {
199                // The prefix `whole` handed back ends at a rev whose bytes
200                // hash to what its row says, so this is artwork the
201                // container cannot show rather than a rev that is not
202                // itself. Either way the reader is owed the empty document
203                // over a crash.
204                tracing::error!("{fault}");
205                container.demote(ReadOnlyReason::WriteFailed(std::io::Error::other(
206                    fault.to_string(),
207                )));
208                Document::default()
209            }
210        };
211        let mut tail = Tail::Whole;
212        match (broken, scanned.end) {
213            (Some(report), _) | (None, End::Broken(report)) => {
214                container.demote(ReadOnlyReason::HistoryBroken(report));
215            }
216            (None, End::Truncated(dropped)) => {
217                tail = Tail::Dropped(dropped);
218                if let Err(WriteRefusal::Io(error)) =
219                    container.truncate_manifest(dropped.good_bytes)
220                {
221                    container.demote(ReadOnlyReason::WriteFailed(error));
222                }
223            }
224            (None, End::Whole) => {}
225        }
226        let projected = projection::found_at(container.root());
227        let stamp = Stamp::at(history.rev(), stamp_of(&history));
228        if let Freshness::Unrecognized = Freshness::of(&projected, &stamp) {
229            tracing::warn!(
230                "{} names a rev this history never wrote: it was hand-edited, or written \
231                 by another build. It is a view, so nothing is lost — the next save \
232                 overwrites it.",
233                super::container::PROJECTION,
234            );
235        }
236        Ok(Self {
237            container,
238            repo: Repo::at(head),
239            trail: history.trail,
240            clock,
241            head: history.head,
242            rows: history.rows,
243            tags: history.tags,
244            tail,
245            projected,
246        })
247    }
248
249    pub fn root(&self) -> &Path {
250        self.container.root()
251    }
252
253    /// Rename this store's container, keeping the lock and the open
254    /// manifest with it (D20). The document is untouched: only what it is
255    /// called changes, so nothing is appended and no rev is spent.
256    ///
257    /// # Errors
258    /// [`Refusal::ReadOnly`] on a container this session may not write —
259    /// renaming someone else's open document is not a read — and
260    /// [`Refusal::Rename`] when the directory does not move, which leaves
261    /// the store where it was.
262    pub fn rename(&mut self, to: &Path) -> Result<(), Refusal> {
263        if self.read_only_reason().is_some() {
264            return Err(Refusal::ReadOnly);
265        }
266        self.container.rename(to).map_err(Refusal::Rename)
267    }
268
269    /// Readable, never writable: [`Self::submit_edit`] and its two
270    /// neighbours are the only way a commit gets into this repo, which is
271    /// what keeps the repo and the files in step.
272    pub fn repo(&self) -> &Repo {
273        &self.repo
274    }
275
276    /// Where this session's history stands: what one press would take
277    /// back, what one would put back, and which rev's document the head
278    /// holds.
279    pub fn trail(&self) -> &Trail {
280        &self.trail
281    }
282
283    pub fn document(&self) -> &Document {
284        self.repo.document()
285    }
286
287    pub fn access(&self) -> &Access {
288        self.container.access()
289    }
290
291    pub fn read_only_reason(&self) -> Option<&ReadOnlyReason> {
292        match self.container.access() {
293            Access::ReadOnly(reason) => Some(reason),
294            Access::Writable(_) => None,
295        }
296    }
297
298    /// Whether load dropped an unfinished row, and which.
299    pub fn tail(&self) -> Tail {
300        self.tail
301    }
302
303    /// The manifest's rows, one per rev, in rev order.
304    pub fn rows(&self) -> &[Row] {
305        &self.rows
306    }
307
308    /// The row rev `at` was written under, or `None` for a rev this
309    /// history does not hold.
310    pub fn row(&self, at: Rev) -> Option<&Row> {
311        let ndx = usize::try_from(at.get().checked_sub(1)?).ok()?;
312        self.rows.get(ndx)
313    }
314
315    /// The row a reader frames rev `at` from: its own, or — for a step —
316    /// the row of the act it moves.
317    ///
318    /// A step frames what it *moved*, so the scope it opens and the view
319    /// it replays are the ones the act was made in, not where the hand
320    /// that pressed undo happened to be standing. The row itself still
321    /// records the undoer's own circumstances; this is the derivation
322    /// §10.1 leaves to read time.
323    pub fn framing(&self, at: Rev) -> Option<&Row> {
324        let mut row = self.row(at)?;
325        // Bounded by the rows behind it: every `of` names an earlier rev,
326        // which the manifest checked when it was read.
327        for _ in 0..self.rows.len() {
328            let Some(of) = row.kind.steps() else {
329                return Some(row);
330            };
331            row = self.row(of)?;
332        }
333        Some(row)
334    }
335
336    /// What this manifest's tag rows call its revs (D18).
337    pub fn tags(&self) -> &Tags {
338        &self.tags
339    }
340
341    /// The document this container holds at `at`, payloads and all.
342    ///
343    /// # Errors
344    /// [`Refusal::Unreachable`] for a rev whose copy the container cannot
345    /// show.
346    pub fn document_at(&self, at: Rev) -> Result<Document, Refusal> {
347        self.container
348            .read_rev(at)
349            .map_err(|why| Refusal::Unreachable {
350                at,
351                why: why.to_string(),
352            })
353    }
354
355    /// Whether `document.json` still shows the rev the manifest heads at
356    /// (D11).
357    pub fn projection(&self) -> Freshness {
358        Freshness::of(&self.projected, &self.stamp())
359    }
360
361    /// Rewrite `document.json` from the head — the one thing "Save" still
362    /// does, now that every commit reaches its rev file as it is made.
363    ///
364    /// A projection nobody recognizes is overwritten rather than kept: the
365    /// revs are the document, and the file beside them makes no claim they
366    /// have to answer for. The caller is the one that says so out loud.
367    ///
368    /// # Errors
369    /// [`Refusal::ReadOnly`] on a container this session may not write, and
370    /// [`Refusal::Projection`] when the file does not land.
371    pub fn save_projection(&mut self) -> Result<Stamp, Refusal> {
372        if self.read_only_reason().is_some() {
373            return Err(Refusal::ReadOnly);
374        }
375        let stamp = self.stamp();
376        projection::write_at(self.container.root(), stamp.clone(), self.repo.document())
377            .map_err(Refusal::Projection)?;
378        self.projected = Found::Stamped(stamp.clone());
379        Ok(stamp)
380    }
381
382    /// The head rev and the digest of its bytes — the one state digest
383    /// this format has (`docs/log-vs-snapshot.md` P5).
384    fn stamp(&self) -> Stamp {
385        Stamp::at(self.repo.rev(), self.head_hash())
386    }
387
388    /// What the head row stamps. Read off the row rather than recomputed:
389    /// the row was written after the bytes landed, and the open checked
390    /// them against each other.
391    fn head_hash(&self) -> Digest {
392        self.rows
393            .last()
394            .map_or_else(|| Digest::of(&[]), |row| row.hash)
395    }
396
397    /// Fold `commit`, write its rev, and append its row. See [`Store`]'s
398    /// note on the write path: a write that fails costs the container its
399    /// lock.
400    ///
401    /// # Errors
402    /// [`Refusal::ReadOnly`] when this handle may not write,
403    /// [`Refusal::Fold`] when the fold refuses the commit, and
404    /// [`Refusal::Append`] when the row does not reach the file.
405    pub fn submit_edit<'a>(
406        &mut self,
407        commit: Commit,
408        by: impl Into<Attribution<'a>>,
409    ) -> Result<Rev, Refusal> {
410        self.write(Step::Edit(commit), by.into())
411    }
412
413    /// Adopt the document the trail's top entry restores, recorded as an
414    /// undo of `edit`.
415    ///
416    /// # Errors
417    /// As [`Self::submit_edit`], with [`Refusal::Step`] in place of
418    /// [`Refusal::Fold`] when the trail will not take the step, and
419    /// [`Refusal::Unreachable`] for a rev this container cannot read back.
420    pub fn undo<'a>(&mut self, edit: Rev, by: impl Into<Attribution<'a>>) -> Result<Rev, Refusal> {
421        self.write(Step::Undo(edit), by.into())
422    }
423
424    /// # Errors
425    /// As [`Self::undo`].
426    pub fn redo<'a>(&mut self, edit: Rev, by: impl Into<Attribution<'a>>) -> Result<Rev, Refusal> {
427        self.write(Step::Redo(edit), by.into())
428    }
429
430    /// Put one of §8.1's names on `at`, or take one off (D18).
431    ///
432    /// One appended row, chained like any other, that no rev is spent on
433    /// and the trail never hears about: tagging is not an edit, and undo
434    /// after it still takes back the last one.
435    ///
436    /// # Errors
437    /// [`Refusal::ReadOnly`] when this handle may not write,
438    /// [`Refusal::NoSuchRev`] for a rev this history does not hold, and
439    /// [`Refusal::Append`] when the row does not reach the file.
440    pub fn tag<'a>(
441        &mut self,
442        at: Rev,
443        name: &str,
444        how: Tagging,
445        by: impl Into<Attribution<'a>>,
446    ) -> Result<(), Refusal> {
447        if self.read_only_reason().is_some() {
448            return Err(Refusal::ReadOnly);
449        }
450        if at == Rev::ZERO || at > self.repo.rev() {
451            return Err(Refusal::NoSuchRev(at));
452        }
453        let by = by.into();
454        let wall_time = self.clock.tick();
455        // A tag has no rev file of its own, so it stamps the head it was
456        // appended under — which is what makes a tag written against
457        // another history detectable.
458        let row = self.row_for(Written {
459            rev: at,
460            kind: match how {
461                Tagging::Added => RowKind::Tag,
462                Tagging::Removed => RowKind::Untag,
463            },
464            label: name.to_owned(),
465            touched: Vec::new(),
466            hash: self.head_hash(),
467            wall_time,
468            by,
469        });
470        match self.container.append(&row) {
471            Ok(()) => {
472                self.head = row.digest();
473                self.tags.apply(at, name, how);
474                Ok(())
475            }
476            Err(WriteRefusal::ReadOnly) => Err(Refusal::ReadOnly),
477            Err(WriteRefusal::Io(error)) => Err(self.parted_from(error)),
478        }
479    }
480
481    /// Take the step, write the rev, then append the row. A write that
482    /// fails leaves this session's document ahead of the files, so the
483    /// container gives up its lock rather than carrying on writing into a
484    /// history with a hole in it.
485    ///
486    /// # Errors
487    /// [`Refusal::ReadOnly`] when this handle may not write,
488    /// [`Refusal::Fold`] or [`Refusal::Step`] when the repo refuses the
489    /// step, and [`Refusal::Append`] when the row does not reach the file.
490    fn write(&mut self, step: Step, by: Attribution<'_>) -> Result<Rev, Refusal> {
491        if self.read_only_reason().is_some() {
492            return Err(Refusal::ReadOnly);
493        }
494        let (rev, kind, label, touched) = match step {
495            Step::Edit(commit) => {
496                let (label, touched) = (commit.label().to_owned(), named(&commit));
497                let rev = self.repo.submit(commit, &mut self.trail)?;
498                (rev, RowKind::Edit, label, touched)
499            }
500            Step::Seed(commit) => {
501                let (label, touched) = (commit.label().to_owned(), named(&commit));
502                let rev = self.repo.fold_one(commit)?.rev();
503                self.trail.seeded(rev);
504                (rev, RowKind::Edit, label, touched)
505            }
506            Step::Undo(edit) => self.step(edit, Direction::Undo)?,
507            Step::Redo(edit) => self.step(edit, Direction::Redo)?,
508        };
509        let wall_time = self.clock.tick();
510        // The rev's own copy of the document — and the payloads it names —
511        // land before the row that names them, so the manifest never names
512        // bytes a crash could still take away.
513        let hash = match self.container.write_rev(rev, self.repo.document()) {
514            Ok(hash) => hash,
515            Err(WriteRefusal::ReadOnly) => return Err(Refusal::ReadOnly),
516            Err(WriteRefusal::Io(error)) => return Err(self.parted_from(error)),
517        };
518        let row = self.row_for(Written {
519            rev,
520            kind,
521            label,
522            touched,
523            hash,
524            wall_time,
525            by,
526        });
527        match self.container.append(&row) {
528            Ok(()) => {
529                self.head = row.digest();
530                self.rows.push(row);
531                Ok(rev)
532            }
533            Err(WriteRefusal::ReadOnly) => Err(Refusal::ReadOnly),
534            Err(WriteRefusal::Io(error)) => Err(self.parted_from(error)),
535        }
536    }
537
538    /// Take one history step, adopting the rev copy this container holds
539    /// (S6), and carry the stepped row's own `touched` names onto the row
540    /// this step writes — so a step frames what it moved (§10.1).
541    fn step(
542        &mut self,
543        edit: Rev,
544        direction: Direction,
545    ) -> Result<(Rev, RowKind, String, Vec<EntityRef>), Refusal> {
546        // The trail is asked first, so a spent step is reported as one
547        // rather than as a rev this history does not hold.
548        let entry = self.trail.stepping(edit, direction)?;
549        let stepped = self.row(entry.rev).ok_or(Refusal::NoSuchRev(edit))?;
550        let (label, touched) = (stepped.label.clone(), stepped.touched.clone());
551        let rev = crate::doc::stepped(
552            &mut self.repo,
553            &mut self.trail,
554            crate::doc::Stepping {
555                edit,
556                direction,
557                label: &label,
558            },
559            |at| {
560                self.container
561                    .read_rev(at)
562                    .map_err(|why| Refusal::Unreachable {
563                        at,
564                        why: why.to_string(),
565                    })
566            },
567        )?;
568        let kind = match direction {
569            Direction::Undo => RowKind::Undo { of: edit },
570            Direction::Redo => RowKind::Redo { of: edit },
571        };
572        Ok((rev, kind, format!("{} {label}", direction.verb()), touched))
573    }
574
575    fn row_for(&mut self, written: Written<'_>) -> Row {
576        let (touched, truncated) = Row::naming(written.touched);
577        Row {
578            rev: written.rev,
579            kind: written.kind,
580            wall_time: written.wall_time,
581            author: written.by.author.clone(),
582            label: written.label,
583            scope: written.by.standing.path().clone(),
584            scope_names: written.by.standing.names().to_vec(),
585            camera: written.by.camera,
586            touched,
587            truncated,
588            hash: written.hash,
589            parent: self.head,
590        }
591    }
592
593    /// This session's document is now ahead of the files, so the container
594    /// gives up its lock rather than carrying on writing into a history
595    /// with a hole in it.
596    fn parted_from(&mut self, error: std::io::Error) -> Refusal {
597        let echo = std::io::Error::other(error.to_string());
598        self.container.demote(ReadOnlyReason::WriteFailed(error));
599        Refusal::Append(echo)
600    }
601}
602
603/// Everything a row is minted from beyond the chain it links into.
604struct Written<'a> {
605    rev: Rev,
606    kind: RowKind,
607    label: String,
608    touched: Vec<EntityRef>,
609    hash: Digest,
610    wall_time: WallTime,
611    by: Attribution<'a>,
612}
613
614/// The entities a commit named, in the spelling §10.1 records them in.
615fn named(commit: &Commit) -> Vec<EntityRef> {
616    let mut touched: Vec<EntityRef> = Vec::new();
617    for target in commit
618        .ops()
619        .iter()
620        .map(blockworx_doc::opcode::OpCodes::target)
621    {
622        if !touched.contains(&target) {
623            touched.push(target);
624        }
625    }
626    touched
627}
628
629/// The longest prefix of `rows` whose last rev the container can show,
630/// with the break that cut it short.
631///
632/// Only the *head* is checked at open: every other rev file is
633/// `blockworx verify`'s business, and a hole in one of them is a finding
634/// rather than a reason to refuse the document.
635fn whole(
636    container: &Container,
637    mut rows: Vec<manifest::Verified>,
638) -> (Vec<manifest::Verified>, Option<manifest::BreakReport>) {
639    let backing = container.revs();
640    let mut broken = None;
641    // A tag spends no rev and stamps the head it was appended under, so
642    // the row that names the *document* is the last one that journals.
643    while let Some(head) = rows
644        .iter()
645        .rev()
646        .find(|verified| verified.row.kind.takes_a_rev())
647    {
648        let (at, named) = (head.row.rev, head.row.hash);
649        let Err(fault) = revs::witnessed(&backing, at, named) else {
650            break;
651        };
652        broken.get_or_insert(manifest::BreakReport {
653            at: head.at,
654            fault: Fault::Unwitnessed {
655                at,
656                why: fault.to_string(),
657            },
658        });
659        rows.retain(|verified| verified.row.rev < at);
660    }
661    (rows, broken)
662}
663
664/// What the head row stamps, for a history read off a file. Rev 0 stamps
665/// the nothing it was written as.
666fn stamp_of(history: &History) -> Digest {
667    history
668        .rows
669        .last()
670        .map_or_else(|| Digest::of(&[]), |row| row.hash)
671}