Skip to main content

lean_ctx_doc/
local.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! A project's docs without Hive: an append-only journal of the same C0 2.2
4//! doc events a vault holds (`node.add`, `field.set`, `edge.add`,
5//! `retract`), folded with [`crate::replica::assemble`], so a local page reads
6//! exactly like a vault page.
7//!
8//! The journal is one JSON object per line (`journal.jsonl` in the store
9//! directory the caller names). Each write is one revision: its events share
10//! a `rev`, a time and an author. Nothing is ever rewritten; history is the
11//! journal itself, and the page as of any revision is the fold of the
12//! journal up to it. Writes take an exclusive file lock and plan against the
13//! state they read under it, so two writers never build on a stale head.
14
15use std::collections::{BTreeMap, BTreeSet};
16use std::fs::{self, File, OpenOptions};
17use std::io::{Read, Seek, SeekFrom, Write};
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22
23use crate::model::{Note, Page, PageStatus, Section};
24use crate::replica::{DocSpace, LogEvent, assemble};
25use crate::write::EventSpec;
26
27/// The journal's file name inside the store directory.
28pub const JOURNAL: &str = "journal.jsonl";
29/// A store refuses writes once its journal reaches this size, and refuses to
30/// read a larger one: a project's docs stay a bounded read.
31pub const MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024;
32/// The most events one revision may carry.
33pub const MAX_EVENTS_PER_REVISION: usize = 4096;
34/// The longest journal line a read accepts.
35const MAX_LINE_BYTES: usize = 256 * 1024;
36
37/// Who wrote a revision.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Author {
40    /// `agent` or `person`.
41    pub kind: String,
42    /// The agent id or the person's name, as the writer gave it.
43    pub label: String,
44}
45
46impl Author {
47    pub fn agent(label: impl Into<String>) -> Self {
48        Self {
49            kind: "agent".into(),
50            label: label.into(),
51        }
52    }
53
54    pub fn person(label: impl Into<String>) -> Self {
55        Self {
56            kind: "person".into(),
57            label: label.into(),
58        }
59    }
60}
61
62/// One journal line: a doc event and the revision it belongs to.
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct Entry {
65    /// The revision (one write); 1-based, increasing.
66    pub rev: u64,
67    /// The event's position in the journal; 1-based, increasing.
68    pub seq: u64,
69    /// RFC 3339 UTC time of the revision.
70    pub at: String,
71    pub author: Author,
72    /// What the write was (`write`, `edit`, `note`, `move`, `delete`, …).
73    pub action: String,
74    pub op: String,
75    pub target: String,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub node_type: Option<String>,
78    pub body: Map<String, Value>,
79}
80
81impl Entry {
82    /// The event id a later CAS write names as its base.
83    pub fn event_id(&self) -> String {
84        format!("local_{:012}", self.seq)
85    }
86
87    fn log_event(&self) -> LogEvent {
88        LogEvent {
89            seq: self.seq,
90            event_id: self.event_id(),
91            op: self.op.clone(),
92            target: Some(self.target.clone()),
93            node_type: self.node_type.clone(),
94            body: Some(Value::Object(self.body.clone())),
95        }
96    }
97}
98
99/// Why the store refused.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum LocalError {
102    Io(String),
103    /// Line `line` (1-based) of the journal is not a journal entry.
104    Corrupt {
105        line: usize,
106    },
107    /// The journal reached [`MAX_JOURNAL_BYTES`].
108    Full,
109    /// A revision with too many events.
110    TooLarge(usize),
111}
112
113impl std::fmt::Display for LocalError {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        match self {
116            Self::Io(e) => write!(f, "local docs: {e}"),
117            Self::Corrupt { line } => write!(f, "local docs: journal line {line} is unreadable"),
118            Self::Full => write!(
119                f,
120                "local docs: the journal reached {} MiB",
121                MAX_JOURNAL_BYTES / (1024 * 1024)
122            ),
123            Self::TooLarge(n) => write!(
124                f,
125                "local docs: {n} events in one write (at most {MAX_EVENTS_PER_REVISION})"
126            ),
127        }
128    }
129}
130
131impl std::error::Error for LocalError {}
132
133fn io(e: &std::io::Error) -> LocalError {
134    LocalError::Io(e.to_string())
135}
136
137/// One revision of a page.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
139pub struct Revision {
140    pub rev: u64,
141    pub at: String,
142    pub author: Author,
143    pub action: String,
144    /// The events of the revision that touch the page.
145    pub events: usize,
146}
147
148/// What a write did.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct Committed {
151    /// The new revision; `None` when the plan had nothing to write.
152    pub rev: Option<u64>,
153    pub events: usize,
154}
155
156/// The doc store of one project, at a directory the caller chose.
157#[derive(Debug, Clone)]
158pub struct LocalDocs {
159    dir: PathBuf,
160}
161
162impl LocalDocs {
163    pub fn at(dir: impl Into<PathBuf>) -> Self {
164        Self { dir: dir.into() }
165    }
166
167    pub fn dir(&self) -> &Path {
168        &self.dir
169    }
170
171    fn journal(&self) -> PathBuf {
172        self.dir.join(JOURNAL)
173    }
174
175    /// Every entry, in journal order; empty when the store was never written.
176    ///
177    /// # Errors
178    /// An unreadable or oversized journal, or a line that is not an entry.
179    pub fn entries(&self) -> Result<Vec<Entry>, LocalError> {
180        match File::open(self.journal()) {
181            Ok(mut f) => read_entries(&mut f),
182            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
183            Err(e) => Err(io(&e)),
184        }
185    }
186
187    /// The pages as of now.
188    ///
189    /// # Errors
190    /// As [`Self::entries`].
191    pub fn space(&self) -> Result<DocSpace, LocalError> {
192        Ok(space_of(&self.entries()?, None, &[]))
193    }
194
195    /// The live pages as [`Page`] values, by slug: what a vault takes on
196    /// link ([`crate::write::create_page`]). Section bodies keep their
197    /// storage form (`hive:` links name local page ids).
198    ///
199    /// # Errors
200    /// As [`Self::entries`].
201    pub fn pages(&self) -> Result<Vec<Page>, LocalError> {
202        Ok(pages_of(&self.space()?))
203    }
204
205    /// Runs `plan` against the current pages under the store's lock and
206    /// appends its events as one revision by `author` at `at`.
207    ///
208    /// # Errors
209    /// `plan`'s own error; a full journal or too many events; I/O.
210    pub fn commit<E: From<LocalError>>(
211        &self,
212        author: &Author,
213        at: &str,
214        action: &str,
215        plan: impl FnOnce(&DocSpace) -> Result<Vec<EventSpec>, E>,
216    ) -> Result<Committed, E> {
217        create_private_dir(&self.dir)?;
218        let mut file = open_journal(&self.journal())?;
219        let _lock = Lock::exclusive(&file)?;
220        let len = file.metadata().map_err(|e| io(&e))?.len();
221        if len >= MAX_JOURNAL_BYTES {
222            return Err(LocalError::Full.into());
223        }
224        file.seek(SeekFrom::Start(0)).map_err(|e| io(&e))?;
225        let entries = read_entries(&mut file)?;
226        let mut specs = plan(&space_of(&entries, None, &[]))?;
227        if specs.is_empty() {
228            return Ok(Committed {
229                rev: None,
230                events: 0,
231            });
232        }
233        if specs.len() > MAX_EVENTS_PER_REVISION {
234            return Err(LocalError::TooLarge(specs.len()).into());
235        }
236        let rev = entries.last().map_or(1, |e| e.rev + 1);
237        let first = entries.last().map_or(1, |e| e.seq + 1);
238        let ids: Vec<String> = (0..specs.len())
239            .map(|i| format!("local_{:012}", first + i as u64))
240            .collect();
241        crate::write::chain(&mut specs, &ids);
242        let mut out = String::new();
243        for (i, spec) in specs.iter().enumerate() {
244            let entry = Entry {
245                rev,
246                seq: first + i as u64,
247                at: at.to_owned(),
248                author: author.clone(),
249                action: action.to_owned(),
250                op: spec.op.to_owned(),
251                target: spec.target.clone(),
252                node_type: spec.node_type.map(str::to_owned),
253                body: spec.body.clone(),
254            };
255            let line = serde_json::to_string(&entry).map_err(|e| LocalError::Io(e.to_string()))?;
256            out.push_str(&line);
257            out.push('\n');
258        }
259        if len + out.len() as u64 > MAX_JOURNAL_BYTES {
260            return Err(LocalError::Full.into());
261        }
262        // A torn tail (a crash mid-append) is dropped before the next append,
263        // so the journal stays one entry per line.
264        let keep = complete_len(&mut file, len)?;
265        if keep != len {
266            file.set_len(keep).map_err(|e| io(&e))?;
267        }
268        file.seek(SeekFrom::Start(keep)).map_err(|e| io(&e))?;
269        file.write_all(out.as_bytes()).map_err(|e| io(&e))?;
270        file.sync_data().map_err(|e| io(&e))?;
271        Ok(Committed {
272            rev: Some(rev),
273            events: specs.len(),
274        })
275    }
276}
277
278/// The pages folded from `entries` up to revision `upto` (all when `None`),
279/// with `extra` applied on top (a read-time recheck that is never stored).
280pub fn space_of(entries: &[Entry], upto: Option<u64>, extra: &[EventSpec]) -> DocSpace {
281    let mut log: Vec<LogEvent> = entries
282        .iter()
283        .filter(|e| upto.is_none_or(|r| e.rev <= r))
284        .map(Entry::log_event)
285        .collect();
286    let next = log.last().map_or(1, |e| e.seq + 1);
287    log.extend(extra.iter().enumerate().map(|(i, s)| LogEvent {
288        seq: next + i as u64,
289        event_id: format!("check_{i}"),
290        op: s.op.to_owned(),
291        target: Some(s.target.clone()),
292        node_type: s.node_type.map(str::to_owned),
293        body: Some(Value::Object(s.body.clone())),
294    }));
295    assemble(&log)
296}
297
298/// The ids of `page` and of every node that ever belonged to it (sections
299/// and notes, retracted ones too).
300fn page_nodes(entries: &[Entry], page: &str) -> BTreeSet<String> {
301    let mut nodes = BTreeSet::from([page.to_owned()]);
302    let mut notes_on: Vec<(String, String)> = Vec::new();
303    for e in entries.iter().filter(|e| e.op == "edge.add") {
304        let get = |k: &str| e.body.get(k).and_then(Value::as_str).unwrap_or_default();
305        match get("kind") {
306            "part_of" if get("to") == page => {
307                nodes.insert(get("from").to_owned());
308            }
309            "about" => notes_on.push((get("from").to_owned(), get("to").to_owned())),
310            _ => {}
311        }
312    }
313    for (note, about) in notes_on {
314        if nodes.contains(&about) {
315            nodes.insert(note);
316        }
317    }
318    nodes
319}
320
321/// The revisions that touched `page` (an id), oldest first.
322pub fn history(entries: &[Entry], page: &str) -> Vec<Revision> {
323    let nodes = page_nodes(entries, page);
324    let touches = |e: &Entry| {
325        nodes.contains(&e.target)
326            || (e.op == "edge.add"
327                && ["from", "to"].iter().any(|k| {
328                    e.body
329                        .get(*k)
330                        .and_then(Value::as_str)
331                        .is_some_and(|v| nodes.contains(v))
332                }))
333    };
334    let mut out: BTreeMap<u64, Revision> = BTreeMap::new();
335    for e in entries.iter().filter(|e| touches(e)) {
336        out.entry(e.rev)
337            .or_insert_with(|| Revision {
338                rev: e.rev,
339                at: e.at.clone(),
340                author: e.author.clone(),
341                action: e.action.clone(),
342                events: 0,
343            })
344            .events += 1;
345    }
346    out.into_values().collect()
347}
348
349/// The last revision that touched each page (by page id), in one pass.
350pub fn last_revisions(entries: &[Entry]) -> BTreeMap<String, Revision> {
351    let mut owner: BTreeMap<&str, &str> = BTreeMap::new();
352    for e in entries.iter().filter(|e| e.op == "edge.add") {
353        let get = |k: &str| e.body.get(k).and_then(Value::as_str);
354        if let (Some("part_of" | "about"), Some(from), Some(to)) =
355            (get("kind"), get("from"), get("to"))
356        {
357            let page = owner.get(to).copied().unwrap_or(to);
358            owner.insert(from, page);
359        }
360    }
361    let mut out: BTreeMap<String, Revision> = BTreeMap::new();
362    for e in entries {
363        let page = match e.op.as_str() {
364            "edge.add" => e
365                .body
366                .get("from")
367                .and_then(Value::as_str)
368                .map(|from| owner.get(from).copied().unwrap_or(from)),
369            _ => Some(owner.get(e.target.as_str()).copied().unwrap_or(&e.target)),
370        };
371        let Some(page) = page else { continue };
372        let slot = out.entry(page.to_owned()).or_insert_with(|| Revision {
373            rev: 0,
374            at: String::new(),
375            author: e.author.clone(),
376            action: String::new(),
377            events: 0,
378        });
379        if slot.rev != e.rev {
380            *slot = Revision {
381                rev: e.rev,
382                at: e.at.clone(),
383                author: e.author.clone(),
384                action: e.action.clone(),
385                events: 0,
386            };
387        }
388        slot.events += 1;
389    }
390    out
391}
392
393/// The id of the page `slug` names now or named last (a deleted or moved
394/// page's history stays reachable by its old slug).
395pub fn page_id_of(entries: &[Entry], slug: &str) -> Option<String> {
396    let mut last: Option<&str> = None;
397    for e in entries {
398        let named = match e.op.as_str() {
399            "node.add" if e.node_type.as_deref() == Some("page") => e.body.get("slug"),
400            "field.set" if e.body.get("field").and_then(Value::as_str) == Some("slug") => {
401                e.body.get("value")
402            }
403            _ => None,
404        };
405        if named.and_then(Value::as_str) == Some(slug) {
406            last = Some(&e.target);
407        }
408    }
409    last.map(str::to_owned)
410}
411
412fn pages_of(space: &DocSpace) -> Vec<Page> {
413    let mut out: Vec<Page> = space
414        .pages
415        .values()
416        .map(|p| Page {
417            slug: p.slug().to_owned(),
418            title: p.title().to_owned(),
419            kind: p.page.str("type").unwrap_or("reference").to_owned(),
420            status: p
421                .page
422                .str("status")
423                .and_then(PageStatus::parse)
424                .unwrap_or(PageStatus::Draft),
425            sections: p
426                .sections
427                .iter()
428                .map(|s| Section {
429                    order: s.order,
430                    text: s.body.clone(),
431                })
432                .collect(),
433            anchors: p
434                .page
435                .fields
436                .get("anchors")
437                .and_then(Value::as_array)
438                .into_iter()
439                .flatten()
440                .filter_map(crate::write::anchor_from_value)
441                .collect(),
442            notes: p
443                .notes
444                .iter()
445                .filter(|n| n.open)
446                .map(|n| Note {
447                    body: n.body.clone(),
448                    covers: n.covers.iter().map(|c| c.reference.clone()).collect(),
449                })
450                .collect(),
451        })
452        .collect();
453    out.sort_by(|a, b| a.slug.cmp(&b.slug));
454    out
455}
456
457fn read_entries(file: &mut File) -> Result<Vec<Entry>, LocalError> {
458    let len = file.metadata().map_err(|e| io(&e))?.len();
459    if len > MAX_JOURNAL_BYTES {
460        return Err(LocalError::Full);
461    }
462    let mut raw = String::new();
463    file.take(MAX_JOURNAL_BYTES)
464        .read_to_string(&mut raw)
465        .map_err(|e| io(&e))?;
466    let mut out = Vec::new();
467    let mut lines = raw.split_inclusive('\n').enumerate().peekable();
468    while let Some((i, line)) = lines.next() {
469        // A last line without its newline is a torn append: not part of the
470        // journal (the next write drops it).
471        if !line.ends_with('\n') && lines.peek().is_none() {
472            break;
473        }
474        let line = line.trim_end();
475        if line.is_empty() {
476            continue;
477        }
478        if line.len() > MAX_LINE_BYTES {
479            return Err(LocalError::Corrupt { line: i + 1 });
480        }
481        let entry: Entry =
482            serde_json::from_str(line).map_err(|_| LocalError::Corrupt { line: i + 1 })?;
483        if out
484            .last()
485            .is_some_and(|p: &Entry| entry.seq <= p.seq || entry.rev < p.rev)
486        {
487            return Err(LocalError::Corrupt { line: i + 1 });
488        }
489        out.push(entry);
490    }
491    Ok(out)
492}
493
494/// The length of the journal up to its last complete line.
495fn complete_len(file: &mut File, len: u64) -> Result<u64, LocalError> {
496    if len == 0 {
497        return Ok(0);
498    }
499    let mut last = [0u8; 1];
500    file.seek(SeekFrom::Start(len - 1)).map_err(|e| io(&e))?;
501    file.read_exact(&mut last).map_err(|e| io(&e))?;
502    if last[0] == b'\n' {
503        return Ok(len);
504    }
505    let mut raw = Vec::new();
506    file.seek(SeekFrom::Start(0)).map_err(|e| io(&e))?;
507    file.read_to_end(&mut raw).map_err(|e| io(&e))?;
508    Ok(raw
509        .iter()
510        .rposition(|b| *b == b'\n')
511        .map_or(0, |i| i as u64 + 1))
512}
513
514fn create_private_dir(dir: &Path) -> Result<(), LocalError> {
515    fs::create_dir_all(dir).map_err(|e| io(&e))?;
516    #[cfg(unix)]
517    {
518        use std::os::unix::fs::PermissionsExt;
519        fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).map_err(|e| io(&e))?;
520    }
521    Ok(())
522}
523
524fn open_journal(path: &Path) -> Result<File, LocalError> {
525    let mut options = OpenOptions::new();
526    options.read(true).write(true).create(true).truncate(false);
527    #[cfg(unix)]
528    {
529        use std::os::unix::fs::OpenOptionsExt;
530        options.mode(0o600);
531    }
532    options.open(path).map_err(|e| io(&e))
533}
534
535/// An exclusive advisory lock on the journal, released on drop (and by the
536/// OS when the process ends).
537/// Holds a duplicate of the journal's handle (same open file description),
538/// so the caller keeps using its own handle while locked.
539struct Lock(File);
540
541impl Lock {
542    fn exclusive(file: &File) -> Result<Self, LocalError> {
543        let held = file.try_clone().map_err(|e| io(&e))?;
544        fs2::FileExt::lock_exclusive(&held).map_err(|e| io(&e))?;
545        Ok(Self(held))
546    }
547}
548
549impl Drop for Lock {
550    fn drop(&mut self) {
551        let _ = fs2::FileExt::unlock(&self.0);
552    }
553}
554
555#[cfg(test)]
556#[path = "local_tests.rs"]
557mod tests;