Skip to main content

blockworx/store/
record.rs

1//! The circumstances of a write: who, when, where they were standing, and
2//! what they were looking at. Rationale:
3//! `docs/single-author-playbook.md`, D9/D15 (the `author` identity), D12
4//! (the hash chain), and `docs/log-vs-snapshot.md` §10.1 (the camera).
5//!
6//! These are the fields a [`manifest`](super::manifest) row is made of,
7//! kept apart from the row itself because the editor builds them at seal
8//! and the store writes them: an [`Attribution`] crosses that seam.
9//!
10//! Target-independent on purpose: Phase 8 reads and writes the same rows
11//! in the browser, where the container is OPFS rather than a directory.
12
13use blockworx_doc::id::BlockId;
14use serde::{Deserialize, Serialize};
15
16/// What the first row chains to. A fixed string rather than zeros, so a
17/// hand-written row claiming an all-zero parent is not mistaken for a
18/// genuine start of history. Changing it invalidates every existing
19/// manifest, which is what the version suffix is for.
20const GENESIS: &str = "blockworx.manifest.v1";
21
22/// The stand-in name for a machine whose environment names nobody. Not a
23/// person's name and not empty, so an audit trail says "we do not know"
24/// rather than appearing to attribute the edit.
25const UNATTRIBUTED: &str = "unattributed author";
26
27/// When a record was written, in unix milliseconds. An instant rather than
28/// a duration, so it is not a [`core::time::Duration`]; the conversion
29/// from `SystemTime` goes through one, and the clock that mints these is
30/// `store::handle::Clock` — native-only, so that this module stays
31/// target-independent.
32#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)]
33#[serde(transparent)]
34pub struct WallTime(u64);
35
36impl WallTime {
37    pub const EPOCH: Self = Self(0);
38
39    pub const fn from_unix_millis(millis: u64) -> Self {
40        Self(millis)
41    }
42
43    pub const fn unix_millis(self) -> u64 {
44        self.0
45    }
46}
47
48/// A blake3 digest in the manifest's durable spelling: 64 lowercase hex
49/// characters. One type for both halves of D12 — the chain link and the
50/// rev file's stamp — so the two cannot be spelled differently.
51#[derive(Clone, Copy, PartialEq, Eq)]
52pub struct Digest([u8; 32]);
53
54impl Digest {
55    /// The parent of the first record.
56    pub fn genesis() -> Self {
57        Self::of(GENESIS.as_bytes())
58    }
59
60    pub fn of(bytes: &[u8]) -> Self {
61        Self(blake3::hash(bytes).into())
62    }
63}
64
65impl std::fmt::Display for Digest {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.write_str(blake3::Hash::from(self.0).to_hex().as_str())
68    }
69}
70
71impl std::fmt::Debug for Digest {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        write!(f, "{self}")
74    }
75}
76
77impl Serialize for Digest {
78    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
79        serializer.collect_str(self)
80    }
81}
82
83impl<'de> Deserialize<'de> for Digest {
84    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
85        let hex = String::deserialize(deserializer)?;
86        blake3::Hash::from_hex(&hex)
87            .map(|hash| Self(*hash.as_bytes()))
88            .map_err(serde::de::Error::custom)
89    }
90}
91
92/// Who wrote a record: D9's *attribution* identity, which must stay
93/// meaningful for the life of a decades-lived document. Never a pointer
94/// into an account system — the record carries the identity itself, so it
95/// survives the death of whatever service verified it.
96#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
97pub struct Identity {
98    pub name: String,
99    /// The stable id an account supplies once D15's authentication ships;
100    /// `null` while identities are locally entered and unverified.
101    #[serde(default)]
102    pub id: Option<String>,
103}
104
105impl Identity {
106    pub fn new(name: impl Into<String>) -> Self {
107        Self {
108            name: name.into(),
109            id: None,
110        }
111    }
112
113    /// The unverified local profile: the name the OS knows this user by.
114    pub fn from_environment() -> Self {
115        let name = std::env::var("USER")
116            .or_else(|_| std::env::var("USERNAME"))
117            .ok()
118            .filter(|name| !name.is_empty())
119            .unwrap_or_else(|| UNATTRIBUTED.to_owned());
120        Self::new(name)
121    }
122}
123
124/// Where the author was standing when a record was written: the blocks
125/// descended into, outermost first, with the empty path meaning the
126/// document root. Purely advisory (D18's neighbour in the punch list) —
127/// replay parses it and nothing else reads it but the chrome.
128#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
129#[serde(transparent)]
130pub struct ScopePath(Vec<BlockId>);
131
132impl ScopePath {
133    /// The document root, for a record nobody stood anywhere to write.
134    pub const ROOT: Self = Self(Vec::new());
135
136    pub fn segments(&self) -> &[BlockId] {
137        &self.0
138    }
139
140    /// Whether the author was standing at the document root.
141    pub fn is_empty(&self) -> bool {
142        self.0.is_empty()
143    }
144
145    /// The block the author was inside — the innermost segment, which is
146    /// the coordinate space a recorded camera is measured in.
147    pub fn innermost(&self) -> Option<BlockId> {
148        self.0.last().copied()
149    }
150}
151
152impl FromIterator<BlockId> for ScopePath {
153    fn from_iter<I: IntoIterator<Item = BlockId>>(ids: I) -> Self {
154        Self(ids.into_iter().collect())
155    }
156}
157
158/// Where the author was standing when a row was written: the world point
159/// at the centre of their view, and how far in they were zoomed.
160///
161/// Centre-and-zoom rather than a rect (§10.1), so the view reproduces
162/// sensibly at any window size — and recorded rather than derived,
163/// because the view the author set is *evidence* of what they were
164/// working on where a region computed from the change is only a guess.
165/// The conversion to and from the canvas's own `Vantage` lives beside the
166/// camera that has the viewport, in `canvas::view`.
167#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
168pub struct Camera {
169    pub x: f32,
170    pub y: f32,
171    pub zoom: f32,
172}
173
174impl Camera {
175    /// A session nobody was looking through: a headless test, a seeded
176    /// container, a command-line write. Recorded rather than left absent,
177    /// so a row's shape never depends on whether there was a window.
178    pub const UNSEEN: Self = Self {
179        x: 0.0,
180        y: 0.0,
181        zoom: 1.0,
182    };
183}
184
185/// Where the author was standing, in both spellings a row keeps: the
186/// blocks the spotlight filters by, and the names §8.1's scope line
187/// reads. Bundled rather than passed as two arguments, because names of a
188/// different length than the ids they spell describe nowhere real.
189#[derive(Clone, Copy)]
190pub struct Standing<'a> {
191    path: &'a ScopePath,
192    names: &'a [String],
193}
194
195impl<'a> Standing<'a> {
196    /// The document root — where a headless write stands.
197    pub const ROOT: Standing<'static> = Standing {
198        path: &ScopePath::ROOT,
199        names: &[],
200    };
201
202    /// `names` spells `path`, outermost first, in the spelling the
203    /// content path along the canvas bottom uses.
204    pub fn new(path: &'a ScopePath, names: &'a [String]) -> Self {
205        debug_assert_eq!(
206            path.segments().len(),
207            names.len(),
208            "a scope's names must spell its own ids",
209        );
210        Self { path, names }
211    }
212
213    pub fn path(self) -> &'a ScopePath {
214        self.path
215    }
216
217    pub fn names(self) -> &'a [String] {
218        self.names
219    }
220}
221
222/// Who wrote a row, where they were standing, and what they were looking
223/// at. One argument rather than three, so a call site that has a path and
224/// a camera can supply them without every call site that has neither
225/// growing arguments: an `&Identity` converts into an unplaced
226/// attribution on its own.
227#[derive(Clone, Copy)]
228pub struct Attribution<'a> {
229    pub author: &'a Identity,
230    pub standing: Standing<'a>,
231    pub camera: Camera,
232}
233
234impl<'a> From<&'a Identity> for Attribution<'a> {
235    fn from(author: &'a Identity) -> Self {
236        Self {
237            author,
238            standing: Standing::ROOT,
239            camera: Camera::UNSEEN,
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn a_digest_survives_the_json_it_is_written_as() {
250        let digest = Digest::of(b"a row");
251        let text = serde_json::to_string(&digest).expect("a digest serializes");
252        assert_eq!(text, format!("\"{digest}\""), "hex text, not a byte array");
253        assert_eq!(
254            serde_json::from_str::<Digest>(&text).expect("and parses back"),
255            digest,
256        );
257    }
258
259    #[test]
260    fn a_digest_refuses_text_that_is_not_a_digest() {
261        assert!(serde_json::from_str::<Digest>("\"deadbeef\"").is_err());
262    }
263
264    #[test]
265    fn an_unnamed_environment_attributes_to_nobody_rather_than_to_an_empty_name() {
266        assert!(!Identity::from_environment().name.is_empty());
267        assert_eq!(Identity::new("ada").id, None);
268    }
269
270    /// An attribution with nothing behind it still carries a camera: a row
271    /// records one whether or not anybody was looking (§10.1).
272    #[test]
273    fn an_unplaced_attribution_stands_at_the_origin_at_unity() {
274        let author = Identity::new("ada");
275        let by = Attribution::from(&author);
276        assert!(by.standing.path().is_empty());
277        assert!(by.standing.names().is_empty());
278        assert_eq!(by.camera, Camera::UNSEEN);
279    }
280
281    #[test]
282    fn a_scope_path_names_the_block_its_camera_is_measured_in() {
283        let root = ScopePath::default();
284        assert!(root.is_empty());
285        assert_eq!(root.innermost(), None);
286
287        let inside: ScopePath = [
288            blockworx_doc::fixtures::block_id(1),
289            blockworx_doc::fixtures::block_id(4),
290        ]
291        .into_iter()
292        .collect();
293        assert_eq!(
294            inside.innermost(),
295            Some(blockworx_doc::fixtures::block_id(4))
296        );
297        assert!(!inside.is_empty());
298    }
299}