Skip to main content

lean_ctx_doc/
write.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Doc writes as unsigned place events (C0 2.2). The engine fills in the
4//! envelope (event id, nonce, commit, HLC, signature) and queues them in
5//! order; CAS-checked `field.set`s carry the head they were built on, so a
6//! batch can chain edits: each event's base is the one before it.
7
8use serde_json::{Map, Value, json};
9
10use crate::anchor::{Anchor, AnchorMode, AnchorRole};
11use crate::model::{Page, PageStatus};
12use crate::replica::{PageView, SectionView};
13use crate::sections;
14
15/// One event to sign and queue.
16#[derive(Debug, Clone, PartialEq)]
17pub struct EventSpec {
18    /// `node.add`, `field.set`, `edge.add` or `retract`.
19    pub op: &'static str,
20    pub target: String,
21    /// For `node.add`: `page`, `section` or `note`.
22    pub node_type: Option<&'static str>,
23    /// The body without `body_nonce`.
24    pub body: Map<String, Value>,
25    /// The head this write was built on (CAS-checked fields only).
26    pub base_event: Option<String>,
27}
28
29/// Makes ids: `"n"` for nodes, `"e"` for edges (`n_…`, `e_…`).
30pub type NewId<'a> = &'a mut dyn FnMut(&str) -> String;
31
32fn obj(v: Value) -> Map<String, Value> {
33    match v {
34        Value::Object(m) => m,
35        _ => Map::new(),
36    }
37}
38
39/// The wire form of an anchor: `{ref, digest, commit, mode?, role?}`.
40pub fn anchor_value(a: &Anchor) -> Value {
41    let mut m = obj(json!({ "ref": a.reference(), "digest": a.digest }));
42    if let Some(c) = &a.commit {
43        m.insert("commit".into(), Value::from(c.as_str()));
44    }
45    if a.mode == AnchorMode::Shape {
46        m.insert("mode".into(), Value::from("shape"));
47    }
48    if a.role == AnchorRole::Cites {
49        m.insert("role".into(), Value::from("cites"));
50    }
51    Value::Object(m)
52}
53
54/// The anchor of a wire value [`anchor_value`] wrote; `None` for an unknown
55/// scheme.
56pub fn anchor_from_value(a: &Value) -> Option<Anchor> {
57    use crate::anchor::AnchorKind;
58    let (scheme, path) = a.get("ref")?.as_str()?.split_once(':')?;
59    let kind = match scheme {
60        "file" => AnchorKind::File,
61        "dir" => AnchorKind::Dir,
62        "glob" => AnchorKind::Glob,
63        _ => return None,
64    };
65    let text = |k: &str| a.get(k).and_then(Value::as_str);
66    Some(Anchor {
67        kind,
68        mode: if text("mode") == Some("shape") {
69            AnchorMode::Shape
70        } else {
71            AnchorMode::Content
72        },
73        role: if text("role") == Some("cites") {
74            AnchorRole::Cites
75        } else {
76            AnchorRole::Derived
77        },
78        path: path.to_owned(),
79        digest: text("digest").unwrap_or_default().to_owned(),
80        commit: text("commit").map(str::to_owned),
81    })
82}
83
84fn status_str(s: PageStatus) -> &'static str {
85    match s {
86        PageStatus::Draft => "draft",
87        PageStatus::Canonical => "canonical",
88        PageStatus::Retired => "retired",
89    }
90}
91
92fn node_add(node_type: &'static str, target: String, body: Value) -> EventSpec {
93    EventSpec {
94        op: "node.add",
95        target,
96        node_type: Some(node_type),
97        body: obj(body),
98        base_event: None,
99    }
100}
101
102fn edge_add(kind: &str, from: &str, to: &str, new_id: NewId<'_>) -> EventSpec {
103    EventSpec {
104        op: "edge.add",
105        target: new_id("e"),
106        node_type: None,
107        body: obj(json!({ "kind": kind, "from": from, "to": to })),
108        base_event: None,
109    }
110}
111
112/// The events that create `page`: the page node, its sections with their
113/// `part_of` edges, its notes with their `about` edges (to the page) and a
114/// `links_to` edge per linked page id. Bodies must already be in storage
115/// form ([`crate::links::to_storage`]). Returns the events and the page id.
116pub fn create_page(
117    page: &Page,
118    links_to: &[String],
119    new_id: NewId<'_>,
120) -> (Vec<EventSpec>, String) {
121    let id = new_id("n");
122    let mut body = obj(json!({
123        "slug": page.slug,
124        "title": page.title,
125        "type": page.kind,
126        "status": status_str(page.status),
127    }));
128    if !page.anchors.is_empty() {
129        body.insert(
130            "anchors".into(),
131            page.anchors.iter().map(anchor_value).collect(),
132        );
133    }
134    let mut out = vec![node_add("page", id.clone(), Value::Object(body))];
135    for s in &page.sections {
136        let sid = new_id("n");
137        out.push(node_add(
138            "section",
139            sid.clone(),
140            json!({ "body": s.text, "order": s.order }),
141        ));
142        out.push(edge_add("part_of", &sid, &id, new_id));
143    }
144    for n in &page.notes {
145        let nid = new_id("n");
146        let covers: Vec<Value> = n
147            .covers
148            .iter()
149            .map(|r| json!({ "ref": r, "digest": Value::Null }))
150            .collect();
151        let mut nb = obj(json!({ "body": n.body, "status": "open" }));
152        if !covers.is_empty() {
153            nb.insert("covers".into(), Value::Array(covers));
154        }
155        out.push(node_add("note", nid.clone(), Value::Object(nb)));
156        out.push(edge_add("about", &nid, &id, new_id));
157    }
158    for to in links_to {
159        out.push(edge_add("links_to", &id, to, new_id));
160    }
161    (out, id)
162}
163
164/// A CAS-checked `field.set` of `field` on a node whose head is `base`.
165pub fn field_set(target: &str, field: &str, value: Value, base: &str) -> EventSpec {
166    let mut body = Map::new();
167    body.insert("field".into(), Value::from(field));
168    body.insert("value".into(), value);
169    EventSpec {
170        op: "field.set",
171        target: target.to_owned(),
172        node_type: None,
173        body,
174        base_event: Some(base.to_owned()),
175    }
176}
177
178/// Replaces the body of `section`.
179pub fn edit_section(section: &SectionView, body: &str) -> EventSpec {
180    field_set(&section.id, "body", Value::from(body), &section.head)
181}
182
183/// Inserts a section with `text` after the section at `after` (`None`: at
184/// the front). When no order fits between the neighbours the page is
185/// renumbered first, one CAS `field.set order` per section.
186pub fn insert_section(
187    page: &PageView,
188    after: Option<usize>,
189    text: &str,
190    new_id: NewId<'_>,
191) -> Vec<EventSpec> {
192    let secs = &page.sections;
193    let before = after.and_then(|i| secs.get(i)).map(|s| s.order);
194    let next = after.map_or(0, |i| i + 1);
195    let behind = secs.get(next).map(|s| s.order);
196    let mut out = Vec::new();
197    let order = sections::order_between(before, behind).unwrap_or_else(|| {
198        // No gap left: every section moves to `(i + 1) * ORDER_GAP`, the new
199        // one taking the slot at `next`.
200        let fresh = sections::renumber(secs.len() + 1);
201        for (i, s) in secs.iter().enumerate() {
202            let new = fresh[if i >= next { i + 1 } else { i }];
203            if new != s.order {
204                out.push(field_set(&s.id, "order", Value::from(new), &s.head));
205            }
206        }
207        fresh[next]
208    });
209    let sid = new_id("n");
210    out.push(node_add(
211        "section",
212        sid.clone(),
213        json!({ "body": text, "order": order }),
214    ));
215    out.push(edge_add("part_of", &sid, &page.page.id, new_id));
216    out
217}
218
219/// A note about `about` (a page or section id); `covers` are `(ref, digest)`
220/// pairs the note accounts for.
221pub fn add_note(
222    about: &str,
223    body: &str,
224    covers: &[(String, Option<String>)],
225    new_id: NewId<'_>,
226) -> Vec<EventSpec> {
227    let id = new_id("n");
228    let mut nb = obj(json!({ "body": body, "status": "open" }));
229    if !covers.is_empty() {
230        let list: Vec<Value> = covers
231            .iter()
232            .map(|(r, d)| json!({ "ref": r, "digest": d }))
233            .collect();
234        nb.insert("covers".into(), Value::Array(list));
235    }
236    vec![
237        node_add("note", id.clone(), Value::Object(nb)),
238        edge_add("about", &id, about, new_id),
239    ]
240}
241
242/// Confirms a page against the checkout: new anchors (CAS), then the
243/// machine verdicts `anchor_state: fresh` and an empty `drift` (exempt).
244/// A re-anchor carries `verified_at` (ms) in its body: C0 refuses one
245/// without it.
246pub fn confirm(page: &PageView, anchors: &[Anchor], verified_at_ms: u64) -> Vec<EventSpec> {
247    let value: Vec<Value> = anchors.iter().map(anchor_value).collect();
248    let exempt = |field: &str, value: Value| EventSpec {
249        op: "field.set",
250        target: page.page.id.clone(),
251        node_type: None,
252        body: obj(json!({ "field": field, "value": value })),
253        base_event: None,
254    };
255    let mut anchors = field_set(
256        &page.page.id,
257        "anchors",
258        Value::Array(value),
259        &page.page.head,
260    );
261    anchors
262        .body
263        .insert("verified_at".into(), Value::from(verified_at_ms));
264    vec![
265        anchors,
266        exempt("anchor_state", Value::from("fresh")),
267        exempt("drift", Value::Array(Vec::new())),
268    ]
269}
270
271/// `retract` of a node or an edge with `reason` (2–500 characters).
272pub fn retract(target: &str, reason: &str) -> EventSpec {
273    EventSpec {
274        op: "retract",
275        target: target.to_owned(),
276        node_type: None,
277        body: obj(json!({ "reason": reason })),
278        base_event: None,
279    }
280}
281
282/// The events that turn `page` into `markdown` (storage form: links
283/// already `hive:`). Unchanged sections stay; a changed stretch becomes edits
284/// of the sections it replaces, then inserts or retractions for the rest. A
285/// new `# ` title is a CAS `field.set title`. `links_want` are the page ids
286/// the new body links to; `links_now` the live `links_to` edges.
287pub fn revise_page(
288    page: &PageView,
289    markdown: &str,
290    links_now: &[(String, String)],
291    links_want: &[String],
292    new_id: NewId<'_>,
293) -> Vec<EventSpec> {
294    let (title, wanted) = sections::split(markdown);
295    let mut out = Vec::new();
296    if !title.is_empty() && title != page.title() {
297        out.push(field_set(
298            &page.page.id,
299            "title",
300            Value::from(title),
301            &page.page.head,
302        ));
303    }
304    let old: Vec<&str> = page.sections.iter().map(|s| s.body.as_str()).collect();
305    let new: Vec<&str> = wanted.iter().map(|s| s.text.as_str()).collect();
306    // Each new section either keeps an old one (same text, `Keep`), takes an
307    // old one's place (`Edit`) or is added (`Add`); unmatched old ones go.
308    let plan = align(&old, &new);
309    let mut orders: Vec<Option<u32>> = plan
310        .iter()
311        .map(|step| match step {
312            Step::Keep(i) | Step::Edit(i) => Some(page.sections[*i].order),
313            Step::Add => None,
314        })
315        .collect();
316    let renumber = !fill_orders(&mut orders);
317    let fresh = sections::renumber(plan.len());
318    for (k, step) in plan.iter().enumerate() {
319        let order = if renumber {
320            fresh[k]
321        } else {
322            orders[k].unwrap_or(fresh[k])
323        };
324        match step {
325            Step::Keep(i) | Step::Edit(i) => {
326                let s = &page.sections[*i];
327                if matches!(step, Step::Edit(_)) {
328                    out.push(edit_section(s, new[k]));
329                }
330                if order != s.order {
331                    out.push(field_set(&s.id, "order", Value::from(order), &s.head));
332                }
333            }
334            Step::Add => {
335                let sid = new_id("n");
336                out.push(node_add(
337                    "section",
338                    sid.clone(),
339                    json!({ "body": new[k], "order": order }),
340                ));
341                out.push(edge_add("part_of", &sid, &page.page.id, new_id));
342            }
343        }
344    }
345    let used: std::collections::BTreeSet<usize> = plan
346        .iter()
347        .filter_map(|s| match s {
348            Step::Keep(i) | Step::Edit(i) => Some(*i),
349            Step::Add => None,
350        })
351        .collect();
352    for (i, s) in page.sections.iter().enumerate() {
353        if !used.contains(&i) {
354            out.push(retract(&s.id, "removed from the page"));
355        }
356    }
357    for (edge, to) in links_now {
358        if !links_want.contains(to) {
359            out.push(retract(edge, "the page no longer links there"));
360        }
361    }
362    for to in links_want {
363        if !links_now.iter().any(|(_, t)| t == to) && *to != page.page.id {
364            out.push(edge_add("links_to", &page.page.id, to, new_id));
365        }
366    }
367    out
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371enum Step {
372    Keep(usize),
373    Edit(usize),
374    Add,
375}
376
377/// Aligns `new` sections to `old` ones: the longest common subsequence of
378/// equal texts is kept; between two kept anchors the old sections are
379/// re-used in order for edits, the rest added or dropped.
380fn align(old: &[&str], new: &[&str]) -> Vec<Step> {
381    let (n, m) = (old.len(), new.len());
382    let mut lcs = vec![vec![0u32; m + 1]; n + 1];
383    for i in (0..n).rev() {
384        for j in (0..m).rev() {
385            lcs[i][j] = if old[i] == new[j] {
386                lcs[i + 1][j + 1] + 1
387            } else {
388                lcs[i + 1][j].max(lcs[i][j + 1])
389            };
390        }
391    }
392    let mut steps = Vec::with_capacity(m);
393    let (mut i, mut j) = (0, 0);
394    let mut spare: Vec<usize> = Vec::new();
395    while j < m {
396        if i < n && old[i] == new[j] {
397            spare.clear();
398            steps.push(Step::Keep(i));
399            i += 1;
400            j += 1;
401        } else if i < n && lcs[i + 1][j] >= lcs[i][j + 1] {
402            spare.push(i);
403            i += 1;
404        } else {
405            steps.push(if spare.is_empty() {
406                Step::Add
407            } else {
408                Step::Edit(spare.remove(0))
409            });
410            j += 1;
411        }
412    }
413    steps
414}
415
416/// Fills the `None` orders strictly between their known neighbours; false
417/// when some gap is too small (or the known orders are not increasing).
418fn fill_orders(orders: &mut [Option<u32>]) -> bool {
419    let mut k = 0;
420    let mut prev: Option<u32> = None;
421    while k < orders.len() {
422        if let Some(o) = orders[k] {
423            if prev.is_some_and(|p| o <= p) {
424                return false;
425            }
426            prev = Some(o);
427            k += 1;
428            continue;
429        }
430        let start = k;
431        while k < orders.len() && orders[k].is_none() {
432            k += 1;
433        }
434        let next = orders.get(k).copied().flatten();
435        let lo = prev.unwrap_or(0);
436        let count = (k - start) as u32;
437        let Some(span) = next.map_or(Some(crate::ORDER_GAP.saturating_mul(count + 1)), |hi| {
438            hi.checked_sub(lo)
439        }) else {
440            return false;
441        };
442        let step = span / (count + 1);
443        if step == 0 {
444            return false;
445        }
446        for (n, slot) in orders[start..k].iter_mut().enumerate() {
447            *slot = Some(lo + step * (n as u32 + 1));
448        }
449        prev = orders[k - 1];
450    }
451    true
452}
453
454/// Chains the bases of a batch: a CAS write on a node another event of the
455/// batch already wrote names that event, so the batch applies in order.
456/// `event_ids` are the ids the engine gave each event, in batch order.
457pub fn chain(specs: &mut [EventSpec], event_ids: &[String]) {
458    let mut last: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
459    for (spec, id) in specs.iter_mut().zip(event_ids) {
460        if spec.base_event.is_some() {
461            if let Some(prev) = last.get(&spec.target) {
462                spec.base_event = Some(prev.clone());
463            }
464        }
465        if spec.op != "edge.add" {
466            last.insert(spec.target.clone(), id.clone());
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::replica::{LogEvent, assemble};
475
476    fn ids() -> impl FnMut(&str) -> String {
477        let mut n = 0;
478        move |p: &str| {
479            n += 1;
480            format!("{p}_{n:026}")
481        }
482    }
483
484    /// Applies specs as if the server accepted them, `seq` from `first`.
485    fn apply(specs: &[EventSpec], first: u64) -> Vec<LogEvent> {
486        specs
487            .iter()
488            .enumerate()
489            .map(|(i, s)| LogEvent {
490                seq: first + i as u64,
491                event_id: format!("ev{}", first + i as u64),
492                op: s.op.into(),
493                target: Some(s.target.clone()),
494                node_type: s.node_type.map(Into::into),
495                body: Some(Value::Object(s.body.clone())),
496            })
497            .collect()
498    }
499
500    fn page() -> Page {
501        let (title, secs) = sections::split("# Start\nintro\n## One\na\n## Two\nb\n");
502        Page {
503            slug: "guide/start".into(),
504            title,
505            kind: "guide".into(),
506            status: PageStatus::Draft,
507            sections: secs,
508            anchors: Vec::new(),
509            notes: Vec::new(),
510        }
511    }
512
513    #[test]
514    fn a_created_page_assembles_back_to_its_markdown() {
515        let mut new_id = ids();
516        let (specs, id) = create_page(&page(), &[], &mut new_id);
517        assert_eq!(specs.len(), 1 + 2 * 3);
518        let space = assemble(&apply(&specs, 1));
519        let p = &space.pages[&id];
520        assert_eq!(p.markdown(), "# Start\nintro\n## One\na\n## Two\nb\n");
521    }
522
523    #[test]
524    fn edits_and_inserts_keep_the_order_and_name_their_base() {
525        let mut new_id = ids();
526        let (specs, id) = create_page(&page(), &[], &mut new_id);
527        let mut log = apply(&specs, 1);
528        let space = assemble(&log);
529        let p = &space.pages[&id];
530        let edit = edit_section(&p.sections[1], "## One\nA!\n");
531        assert_eq!(
532            edit.base_event.as_deref(),
533            Some(p.sections[1].head.as_str())
534        );
535        let ins = insert_section(p, Some(0), "## Half\nh\n", &mut new_id);
536        let mut batch = vec![edit];
537        batch.extend(ins);
538        log.extend(apply(&batch, 100));
539        let p = &assemble(&log).pages[&id];
540        assert_eq!(
541            p.markdown(),
542            "# Start\nintro\n## Half\nh\n## One\nA!\n## Two\nb\n"
543        );
544    }
545
546    #[test]
547    fn a_full_gap_renumbers_before_the_insert() {
548        let mut new_id = ids();
549        let (mut specs, id) = create_page(&page(), &[], &mut new_id);
550        // Squeeze the first two sections together: no integer fits between.
551        if let Some(b) = specs
552            .iter_mut()
553            .find(|s| s.body.get("order") == Some(&Value::from(2048)))
554        {
555            b.body.insert("order".into(), Value::from(1025));
556        }
557        let mut log = apply(&specs, 1);
558        let p = assemble(&log).pages[&id].clone();
559        let ins = insert_section(&p, Some(0), "## Mid\nm\n", &mut new_id);
560        assert!(ins.iter().filter(|s| s.op == "field.set").count() >= 2);
561        log.extend(apply(&ins, 100));
562        let p = &assemble(&log).pages[&id];
563        assert_eq!(
564            p.markdown(),
565            "# Start\nintro\n## Mid\nm\n## One\na\n## Two\nb\n"
566        );
567    }
568
569    fn revised(old: &str, new: &str) -> (Vec<EventSpec>, String) {
570        let mut new_id = ids();
571        let (title, secs) = sections::split(old);
572        let p = Page {
573            title,
574            sections: secs,
575            ..page()
576        };
577        let (specs, id) = create_page(&p, &[], &mut new_id);
578        let mut log = apply(&specs, 1);
579        let view = assemble(&log).pages[&id].clone();
580        let rev = revise_page(&view, new, &[], &[], &mut new_id);
581        log.extend(apply(&rev, 100));
582        (rev, assemble(&log).pages[&id].markdown())
583    }
584
585    #[test]
586    fn revise_keeps_unchanged_sections_and_edits_only_what_moved() {
587        let old = "# T\nlead\n## A\na\n## B\nb\n## C\nc\n";
588        let new = "# T\nlead\n## A\na\n## B\nB changed\n## C\nc\n";
589        let (rev, md) = revised(old, new);
590        assert_eq!(md, new);
591        assert_eq!(rev.len(), 1, "one edit: {rev:?}");
592        assert_eq!(rev[0].op, "field.set");
593    }
594
595    #[test]
596    fn revise_inserts_removes_and_retitles() {
597        let old = "# T\nlead\n## A\na\n## B\nb\n";
598        let new = "# New\nlead\n## X\nx\n## B\nb\n## Z\nz\n";
599        let (rev, md) = revised(old, new);
600        assert_eq!(md, new);
601        assert!(
602            rev.iter()
603                .any(|s| s.body.get("field") == Some(&Value::from("title")))
604        );
605        assert!(
606            !rev.iter().any(|s| s.op == "retract"),
607            "A became X by an edit"
608        );
609        let (rev, md) = revised(old, "# T\nlead\n## B\nb\n");
610        assert_eq!(md, "# T\nlead\n## B\nb\n");
611        assert_eq!(rev.iter().filter(|s| s.op == "retract").count(), 1);
612    }
613
614    #[test]
615    fn revise_renumbers_when_inserts_do_not_fit() {
616        let old = "# T\nlead\n## A\na\n";
617        let many = (0..1100).fold(String::new(), |mut acc, i| {
618            acc.push_str("## S");
619            acc.push_str(&i.to_string());
620            acc.push_str("\ns\n");
621            acc
622        });
623        let new = format!("# T\nlead\n{many}## A\na\n");
624        let (_, md) = revised(old, &new);
625        assert_eq!(md, new);
626    }
627
628    #[test]
629    fn revise_keeps_links_to_in_step() {
630        let mut new_id = ids();
631        let (specs, id) = create_page(&page(), &[], &mut new_id);
632        let view = assemble(&apply(&specs, 1)).pages[&id].clone();
633        let now = vec![
634            ("e_old".to_owned(), "n_gone".to_owned()),
635            ("e_keep".to_owned(), "n_b".to_owned()),
636        ];
637        let rev = revise_page(
638            &view,
639            &view.markdown(),
640            &now,
641            &["n_b".into(), "n_c".into()],
642            &mut new_id,
643        );
644        assert_eq!(rev.len(), 2);
645        assert_eq!((rev[0].op, rev[0].target.as_str()), ("retract", "e_old"));
646        assert_eq!(rev[1].body.get("to"), Some(&Value::from("n_c")));
647    }
648
649    #[test]
650    fn chain_names_the_previous_write_of_the_same_node() {
651        let mut specs = vec![
652            field_set("n_a", "body", Value::from("1"), "h0"),
653            field_set("n_a", "body", Value::from("2"), "h0"),
654            field_set("n_b", "body", Value::from("x"), "hb"),
655        ];
656        chain(&mut specs, &["e1".into(), "e2".into(), "e3".into()]);
657        let bases: Vec<_> = specs
658            .iter()
659            .map(|s| s.base_event.clone().unwrap_or_default())
660            .collect();
661        assert_eq!(bases, ["h0", "e1", "hb"]);
662    }
663
664    #[test]
665    fn confirm_sends_anchors_with_base_and_exempt_verdicts() {
666        let mut new_id = ids();
667        let (specs, id) = create_page(&page(), &[], &mut new_id);
668        let p = assemble(&apply(&specs, 1)).pages[&id].clone();
669        let c = confirm(&p, &[], 1_792_800_020_000);
670        assert_eq!(c[0].base_event.as_deref(), Some(p.page.head.as_str()));
671        assert_eq!(c[0].body["verified_at"], 1_792_800_020_000_u64);
672        assert!(c[1].base_event.is_none() && c[2].base_event.is_none());
673    }
674}