Skip to main content

lean_ctx_doc/
replica.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Pages assembled from a place log (C0 2.2): the engine hands over its
4//! verified events, in any order, and reads pages, sections, notes, each
5//! node's head event (the base of a CAS write) and the derived doc state.
6
7use std::collections::BTreeMap;
8
9use serde_json::{Map, Value};
10
11use crate::fold::{self, DocEvent};
12use crate::state::{self, Drift, NoteCovers, Verdict};
13
14/// One verified place event as the engine's replica holds it.
15#[derive(Debug, Clone, PartialEq)]
16pub struct LogEvent {
17    pub seq: u64,
18    pub event_id: String,
19    pub op: String,
20    pub target: Option<String>,
21    pub node_type: Option<String>,
22    /// `None` for a purged or withheld body.
23    pub body: Option<Value>,
24}
25
26/// A doc node with its fields as folded.
27#[derive(Debug, Clone, Default, PartialEq)]
28pub struct DocNode {
29    pub id: String,
30    pub node_type: String,
31    pub fields: Map<String, Value>,
32    pub retracted: bool,
33    /// The event a CAS write names as `base_event`.
34    pub head: String,
35    /// The seq of the node's first `node.add`.
36    pub added_seq: u64,
37}
38
39impl DocNode {
40    pub fn str(&self, field: &str) -> Option<&str> {
41        self.fields.get(field).and_then(Value::as_str)
42    }
43}
44
45/// A section of a page, in reading order.
46#[derive(Debug, Clone, PartialEq)]
47pub struct SectionView {
48    pub id: String,
49    pub order: u32,
50    pub body: String,
51    pub head: String,
52}
53
54/// A note about a page or one of its sections.
55#[derive(Debug, Clone, PartialEq)]
56pub struct NoteView {
57    pub id: String,
58    pub about: String,
59    pub body: String,
60    pub open: bool,
61    pub covers: Vec<Drift>,
62    pub head: String,
63}
64
65/// A live page, assembled.
66#[derive(Debug, Clone, PartialEq)]
67pub struct PageView {
68    pub page: DocNode,
69    pub sections: Vec<SectionView>,
70    pub notes: Vec<NoteView>,
71    pub slug_conflict: bool,
72    pub state: Verdict,
73}
74
75impl PageView {
76    pub fn slug(&self) -> &str {
77        self.page.str("slug").unwrap_or_default()
78    }
79    pub fn title(&self) -> &str {
80        self.page.str("title").unwrap_or_default()
81    }
82    /// The page markdown: `# {title}\n` and the section bodies in order.
83    pub fn markdown(&self) -> String {
84        let mut out = format!("# {}\n", self.title());
85        for s in &self.sections {
86            out.push_str(&s.body);
87        }
88        out
89    }
90}
91
92/// Every live doc page of a place.
93#[derive(Debug, Clone, Default, PartialEq)]
94pub struct DocSpace {
95    /// Page id → page, live pages only.
96    pub pages: BTreeMap<String, PageView>,
97    /// Slug → page id (the slug owner).
98    pub by_slug: BTreeMap<String, String>,
99    /// Page id → its live `links_to` edges as `(edge id, target page id)`.
100    pub links: BTreeMap<String, Vec<(String, String)>>,
101}
102
103impl DocSpace {
104    pub fn page_by_slug(&self, slug: &str) -> Option<&PageView> {
105        self.by_slug.get(slug).and_then(|id| self.pages.get(id))
106    }
107}
108
109const DOC_TYPES: [&str; 3] = ["page", "section", "note"];
110const HEAD_OPS: [&str; 4] = ["node.add", "field.set", "retract", "restore"];
111
112/// Folds `events` into the doc pages of the place.
113pub fn assemble(events: &[LogEvent]) -> DocSpace {
114    let mut ordered: Vec<&LogEvent> = events.iter().collect();
115    ordered.sort_by_key(|e| e.seq);
116    let (nodes, doc_events) = fold_nodes(&ordered);
117    let m = fold::fold(&doc_events);
118
119    let mut sections: BTreeMap<&str, Vec<SectionView>> = BTreeMap::new();
120    for (sec, page) in &m.section_page {
121        let (Some(page), Some(n)) = (page, nodes.get(sec)) else {
122            continue;
123        };
124        if n.retracted {
125            continue;
126        }
127        sections.entry(page).or_default().push(SectionView {
128            id: n.id.clone(),
129            order: n.fields.get("order").and_then(Value::as_u64).unwrap_or(0) as u32,
130            body: n.str("body").unwrap_or_default().to_owned(),
131            head: n.head.clone(),
132        });
133    }
134    let page_of = |about: &str| -> Option<String> {
135        let n = nodes.get(about).filter(|n| !n.retracted)?;
136        match n.node_type.as_str() {
137            "page" => Some(about.to_owned()),
138            "section" => m.section_page.get(about).cloned().flatten(),
139            _ => None,
140        }
141    };
142    let mut notes: BTreeMap<String, Vec<NoteView>> = BTreeMap::new();
143    for (note, about) in &m.note_about {
144        let (Some(about), Some(n)) = (about, nodes.get(note)) else {
145            continue;
146        };
147        if n.retracted {
148            continue;
149        }
150        if let Some(page) = page_of(about) {
151            notes.entry(page).or_default().push(NoteView {
152                id: n.id.clone(),
153                about: about.clone(),
154                body: n.str("body").unwrap_or_default().to_owned(),
155                open: n.str("status") != Some("resolved"),
156                covers: drift_list(n.fields.get("covers")),
157                head: n.head.clone(),
158            });
159        }
160    }
161
162    let mut space = DocSpace::default();
163    for n in nodes
164        .values()
165        .filter(|n| n.node_type == "page" && !n.retracted)
166    {
167        let mut secs = sections.remove(n.id.as_str()).unwrap_or_default();
168        secs.sort_by(|a, b| (a.order, &a.id).cmp(&(b.order, &b.id)));
169        let page_notes = notes.remove(&n.id).unwrap_or_default();
170        let state = page_state(n, &page_notes);
171        space.pages.insert(
172            n.id.clone(),
173            PageView {
174                page: n.clone(),
175                sections: secs,
176                notes: page_notes,
177                slug_conflict: m.slug_conflict.contains(&n.id),
178                state,
179            },
180        );
181    }
182    space.by_slug = m.slug_owner;
183    space.links = links_of(&ordered);
184    space
185}
186
187/// The live `links_to` edges of the log, by their `from` page.
188fn links_of(ordered: &[&LogEvent]) -> BTreeMap<String, Vec<(String, String)>> {
189    let mut edges: BTreeMap<&str, (&str, &str, bool)> = BTreeMap::new();
190    for e in ordered {
191        let Some(target) = e.target.as_deref() else {
192            continue;
193        };
194        let get = |k: &str| {
195            e.body
196                .as_ref()
197                .and_then(|b| b.get(k))
198                .and_then(Value::as_str)
199        };
200        match e.op.as_str() {
201            "edge.add" if get("kind") == Some("links_to") => {
202                if let (Some(from), Some(to)) = (get("from"), get("to")) {
203                    edges.entry(target).or_insert((from, to, true));
204                }
205            }
206            "retract" | "restore" => {
207                if let Some(edge) = edges.get_mut(target) {
208                    edge.2 = e.op == "restore";
209                }
210            }
211            _ => {}
212        }
213    }
214    let mut out: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
215    for (id, (from, to, live)) in edges {
216        if live {
217            out.entry(from.to_owned())
218                .or_default()
219                .push((id.to_owned(), to.to_owned()));
220        }
221    }
222    out
223}
224
225/// The doc nodes (fields folded, head and first seq) and the membership
226/// events of the log.
227fn fold_nodes(ordered: &[&LogEvent]) -> (BTreeMap<String, DocNode>, Vec<DocEvent>) {
228    let mut nodes: BTreeMap<String, DocNode> = BTreeMap::new();
229    let mut doc_events = Vec::new();
230    for e in ordered {
231        let Some(target) = e.target.as_deref() else {
232            continue;
233        };
234        let body = e.body.as_ref();
235        match e.op.as_str() {
236            "node.add" => {
237                let Some(t) = e.node_type.as_deref().filter(|t| DOC_TYPES.contains(t)) else {
238                    continue;
239                };
240                let mut fields = body.and_then(Value::as_object).cloned().unwrap_or_default();
241                fields.remove("body_nonce");
242                doc_events.push(DocEvent::NodeAdd {
243                    seq: e.seq,
244                    node: target.to_owned(),
245                    node_type: t.to_owned(),
246                    slug: fields
247                        .get("slug")
248                        .and_then(Value::as_str)
249                        .map(str::to_owned),
250                });
251                nodes.entry(target.to_owned()).or_insert(DocNode {
252                    id: target.to_owned(),
253                    node_type: t.to_owned(),
254                    fields,
255                    added_seq: e.seq,
256                    ..DocNode::default()
257                });
258            }
259            "field.set" => {
260                let Some(n) = nodes.get_mut(target) else {
261                    continue;
262                };
263                if let Some(field) = body.and_then(|b| b.get("field")).and_then(Value::as_str) {
264                    let value = body
265                        .and_then(|b| b.get("value"))
266                        .cloned()
267                        .unwrap_or(Value::Null);
268                    if field == "slug" {
269                        if let Some(s) = value.as_str() {
270                            doc_events.push(DocEvent::SlugSet {
271                                seq: e.seq,
272                                node: target.to_owned(),
273                                slug: s.to_owned(),
274                            });
275                        }
276                    }
277                    n.fields.insert(field.to_owned(), value);
278                }
279            }
280            "edge.add" => {
281                let get = |k: &str| {
282                    body.and_then(|b| b.get(k))
283                        .and_then(Value::as_str)
284                        .unwrap_or_default()
285                        .to_owned()
286                };
287                let kind = get("kind");
288                if kind == "part_of" || kind == "about" {
289                    doc_events.push(DocEvent::EdgeAdd {
290                        seq: e.seq,
291                        edge: target.to_owned(),
292                        kind,
293                        from: get("from"),
294                        to: get("to"),
295                    });
296                }
297            }
298            "retract" | "restore" => {
299                let retract = e.op == "retract";
300                doc_events.push(if retract {
301                    DocEvent::Retract {
302                        seq: e.seq,
303                        target: target.to_owned(),
304                    }
305                } else {
306                    DocEvent::Restore {
307                        seq: e.seq,
308                        target: target.to_owned(),
309                    }
310                });
311                if let Some(n) = nodes.get_mut(target) {
312                    n.retracted = retract;
313                }
314            }
315            _ => {}
316        }
317        if HEAD_OPS.contains(&e.op.as_str()) {
318            if let Some(n) = nodes.get_mut(target) {
319                n.head.clone_from(&e.event_id);
320            }
321        }
322    }
323    (nodes, doc_events)
324}
325
326fn drift_list(v: Option<&Value>) -> Vec<Drift> {
327    v.and_then(Value::as_array)
328        .map(|a| {
329            a.iter()
330                .filter_map(|d| serde_json::from_value(d.clone()).ok())
331                .collect()
332        })
333        .unwrap_or_default()
334}
335
336/// The page's doc state from its own fields and its notes.
337fn page_state(page: &DocNode, notes: &[NoteView]) -> Verdict {
338    let derived: Vec<&str> = page
339        .fields
340        .get("anchors")
341        .and_then(Value::as_array)
342        .into_iter()
343        .flatten()
344        .filter(|a| a.get("role").and_then(Value::as_str).unwrap_or("derived") == "derived")
345        .filter_map(|a| a.get("ref").and_then(Value::as_str))
346        .collect();
347    let drift = drift_list(page.fields.get("drift"));
348    let covers: Vec<NoteCovers> = notes
349        .iter()
350        .map(|n| NoteCovers {
351            open: n.open,
352            covers: n.covers.clone(),
353        })
354        .collect();
355    state::of(page.str("anchor_state"), &derived, &drift, &covers)
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use serde_json::json;
362
363    fn ev(seq: u64, op: &str, target: &str, node_type: Option<&str>, body: Value) -> LogEvent {
364        LogEvent {
365            seq,
366            event_id: format!("ev{seq}"),
367            op: op.into(),
368            target: Some(target.into()),
369            node_type: node_type.map(Into::into),
370            body: Some(body),
371        }
372    }
373
374    fn log() -> Vec<LogEvent> {
375        vec![
376            ev(
377                1,
378                "node.add",
379                "n_p",
380                Some("page"),
381                json!({"slug": "guide/start", "title": "Start", "type": "guide", "status": "draft",
382                "anchors": [{"ref": "file:a.rs", "digest": "d1", "commit": "c"}, {"ref": "file:b.md", "digest": "d2", "commit": "c", "role": "cites"}]}),
383            ),
384            ev(
385                3,
386                "node.add",
387                "n_s2",
388                Some("section"),
389                json!({"body": "## Two\nb\n", "order": 2048}),
390            ),
391            ev(
392                2,
393                "node.add",
394                "n_s1",
395                Some("section"),
396                json!({"body": "intro\n", "order": 1024}),
397            ),
398            ev(
399                4,
400                "edge.add",
401                "e_1",
402                None,
403                json!({"kind": "part_of", "from": "n_s1", "to": "n_p"}),
404            ),
405            ev(
406                5,
407                "edge.add",
408                "e_2",
409                None,
410                json!({"kind": "part_of", "from": "n_s2", "to": "n_p"}),
411            ),
412            ev(
413                6,
414                "field.set",
415                "n_s1",
416                None,
417                json!({"field": "body", "value": "Intro.\n"}),
418            ),
419            ev(
420                7,
421                "node.add",
422                "n_n",
423                Some("note"),
424                json!({"body": "a.rs moved", "status": "open", "covers": [{"ref": "file:a.rs", "digest": "d9"}]}),
425            ),
426            ev(
427                8,
428                "edge.add",
429                "e_3",
430                None,
431                json!({"kind": "about", "from": "n_n", "to": "n_s2"}),
432            ),
433            ev(
434                9,
435                "field.set",
436                "n_p",
437                None,
438                json!({"field": "anchor_state", "value": "to_verify"}),
439            ),
440            ev(
441                10,
442                "field.set",
443                "n_p",
444                None,
445                json!({"field": "drift", "value": [{"ref": "file:a.rs", "observed": "d9"}]}),
446            ),
447            ev(
448                11,
449                "node.add",
450                "n_k",
451                Some("fact"),
452                json!({"statement": "not a doc"}),
453            ),
454        ]
455    }
456
457    #[test]
458    fn assembles_pages_in_order_with_heads_and_notes() {
459        let space = assemble(&log());
460        assert_eq!(space.pages.len(), 1);
461        let p = space.page_by_slug("guide/start").expect("page");
462        assert_eq!(p.markdown(), "# Start\nIntro.\n## Two\nb\n");
463        assert_eq!(p.sections[0].head, "ev6", "a field.set moves the head");
464        assert_eq!(p.page.head, "ev10");
465        assert_eq!(p.notes.len(), 1);
466        assert_eq!(
467            p.notes[0].about, "n_s2",
468            "a note about a section belongs to its page"
469        );
470        assert_eq!(p.state.state, crate::state::DocState::Noted);
471    }
472
473    #[test]
474    fn a_retracted_section_or_resolved_note_changes_the_view() {
475        let mut events = log();
476        events.push(ev(12, "retract", "n_s2", None, json!({"reason": "gone"})));
477        events.push(ev(
478            13,
479            "field.set",
480            "n_n",
481            None,
482            json!({"field": "status", "value": "resolved"}),
483        ));
484        let space = assemble(&events);
485        let p = space.page_by_slug("guide/start").expect("page");
486        assert_eq!(p.markdown(), "# Start\nIntro.\n");
487        assert!(p.notes.is_empty(), "the note's section is gone");
488        assert_eq!(p.state.state, crate::state::DocState::Stale);
489    }
490}