oxedyne/fe2o3/fe2o3_pearlite/src/collab/op.rs
4.4 KiB, 23 runs
created by r1870400018:58602, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Mapping an annotation's lifecycle onto the Ore operation vocabulary. |
| 2 | //! |
| 3 | //! The operation log already carries a threaded-discussion vocabulary -- a proposal, replies to it, |
| 4 | //! amendments of it, and a settlement of it -- and an annotation thread is exactly that shape. So no |
| 5 | //! new operation kind is minted: an annotation is a proposal whose body is the annotation itself, and |
| 6 | //! its later life is the operations that already speak about a proposal by name. |
| 7 | //! |
| 8 | //! - **create** -> [`Op::Proposal`], body the annotation. |
| 9 | //! - **reply** -> [`Op::Said`] naming the opening proposal. |
| 10 | //! - **edit** -> [`Op::Amended`] naming it, body the new annotation. |
| 11 | //! - **resolve**/ **withdraw** -> [`Op::Settled`] naming it, `Done` or `Declined`. |
| 12 | //! |
| 13 | //! Each is wrapped in a record with a header (identity and parents) by its author and sealed by |
| 14 | //! [`super::sign`]; this module supplies only the operation payloads and the decoding back out. |
| 15 | |
| 16 | use crate::collab::{ |
| 17 | DocId, |
| 18 | DECODE_LIMITS, |
| 19 | }; |
| 20 | |
| 21 | use oxedyne_fe2o3_austenite::emit::pearl::Annotation; |
| 22 | use oxedyne_fe2o3_ore::{ |
| 23 | id::OpId, |
| 24 | op::{ |
| 25 | Op, |
| 26 | Settled, |
| 27 | }, |
| 28 | }; |
| 29 | |
| 30 | use oxedyne_fe2o3_core::prelude::*; |
| 31 | use oxedyne_fe2o3_jdat::prelude::*; |
| 32 | |
| 33 | /// The forge voice annotation operations are written under, so the log can tell a Pearlite annotation |
| 34 | /// thread from any other proposal it might come to carry. |
| 35 | pub const VOICE: &str = "pearlite"; |
| 36 | |
| 37 | /// Encodes an annotation as the body bytes an operation carries: its [`ToDat`] form in canonical |
| 38 | /// binary daticle, the same encoding the store and the envelope speak, stamped with the document it |
| 39 | /// belongs to so a fold can refuse it if it is replayed onto another document. |
| 40 | fn annotation_body(doc: &DocId, ann: &Annotation) -> Outcome<Vec<u8>> { |
| 41 | let stamped = ann.clone().with_doc_id(doc.as_str()); |
| 42 | Ok(res!(res!(stamped.to_dat()).to_bytes(Vec::new()))) |
| 43 | } |
| 44 | |
| 45 | /// Decodes an annotation from an operation body's bytes, the inverse of what [`create`] and [`edit`] |
| 46 | /// store. |
| 47 | /// |
| 48 | /// The bytes come from a peer over a relay, so they are decoded under the collaboration layer's bounds |
| 49 | /// rather than trusted: a body that nests past [`DECODE_LIMITS`] is refused here rather than recursed |
| 50 | /// into. |
| 51 | pub fn annotation_from_body(body: &[u8]) -> Outcome<Annotation> { |
| 52 | let (dat, _) = res!(Dat::from_bytes_limited(body, &DECODE_LIMITS)); |
| 53 | Annotation::from_dat(dat) |
| 54 | } |
| 55 | |
| 56 | /// A new annotation opens a thread: an [`Op::Proposal`] whose body is the annotation and whose title |
| 57 | /// is the block it hangs on, so a reader listing proposals sees what each is anchored to without |
| 58 | /// decoding the body. The annotation body is stamped with `doc`, binding it to the one document. |
| 59 | pub fn create(doc: &DocId, ann: &Annotation, time: u64) -> Outcome<Op> { |
| 60 | Ok(Op::Proposal { |
| 61 | title: ann.anchor.clone(), |
| 62 | body: res!(annotation_body(doc, ann)), |
| 63 | voice: VOICE.to_string(), |
| 64 | time, |
| 65 | }) |
| 66 | } |
| 67 | |
| 68 | /// A reply to an annotation thread: an [`Op::Said`] naming the opening proposal `on`. |
| 69 | pub fn reply(on: OpId, text: &str, time: u64) -> Op { |
| 70 | Op::Said { |
| 71 | on, |
| 72 | text: text.as_bytes().to_vec(), |
| 73 | voice: VOICE.to_string(), |
| 74 | time, |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// An edit restates the annotation: an [`Op::Amended`] naming the opening proposal `on`, whose body is |
| 79 | /// the new annotation, stamped with `doc` as [`create`] stamps it. The fold takes the latest amendment |
| 80 | /// in time order, and only from the proposal's own signer. |
| 81 | pub fn edit(doc: &DocId, on: OpId, ann: &Annotation, time: u64) -> Outcome<Op> { |
| 82 | Ok(Op::Amended { |
| 83 | on, |
| 84 | title: ann.anchor.clone(), |
| 85 | body: res!(annotation_body(doc, ann)), |
| 86 | voice: VOICE.to_string(), |
| 87 | time, |
| 88 | }) |
| 89 | } |
| 90 | |
| 91 | /// Resolving an annotation marks its thread done: an [`Op::Settled`] in the `Done` state. The |
| 92 | /// annotation stays in a fold -- it is resolved, not gone. |
| 93 | pub fn resolve(on: OpId, mark: Option<OpId>, time: u64) -> Op { |
| 94 | Op::Settled { on, state: Settled::Done, mark, time } |
| 95 | } |
| 96 | |
| 97 | /// Withdrawing an annotation declines its thread: an [`Op::Settled`] in the `Declined` state, which a |
| 98 | /// fold reads as a removal. |
| 99 | pub fn withdraw(on: OpId, mark: Option<OpId>, time: u64) -> Op { |
| 100 | Op::Settled { on, state: Settled::Declined, mark, time } |
| 101 | } |
| 102 | |
| 103 | /// The author's clock reading an operation carries, for the operations that carry one; the fold orders |
| 104 | /// by it. An operation with no time of its own -- none of the annotation operations -- reads as zero. |
| 105 | pub fn time_of(op: &Op) -> u64 { |
| 106 | match op { |
| 107 | Op::Proposal { time, .. } => *time, |
| 108 | Op::Said { time, .. } => *time, |
| 109 | Op::Settled { time, .. } => *time, |
| 110 | Op::Amended { time, .. } => *time, |
| 111 | _ => 0, |
| 112 | } |
| 113 | } |