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}