grove
at @2
1//! grove's state: what the log's records add up to.
2//!
3//! Every record is a JSON object `{"t": type, "at": ms, "by": actor, …}`. `apply` folds one
4//! record into the state. Replaying the log from the start rebuilds the state exactly, so
5//! `apply` must be deterministic and must accept only records the store would have written:
6//! a record that doesn't fit means the log is damaged, and the store refuses to open.
7//!
8//! `apply` is all or nothing: it checks a record completely before changing anything, so the
9//! store can use it to validate a write before appending it.
10
11use crate::hex;
12use crate::json::{obj, Value};
13use crate::objects::Id;
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct Actor {
17 pub person: String,
18 /// The agent acting for the person, if it wasn't the person themselves.
19 pub agent: Option<String>,
20}
21
22impl Actor {
23 pub fn person(name: &str) -> Actor {
24 Actor { person: name.into(), agent: None }
25 }
26 pub fn agent(person: &str, agent: &str) -> Actor {
27 Actor { person: person.into(), agent: Some(agent.into()) }
28 }
29 pub fn to_json(&self) -> Value {
30 obj([("person", self.person.clone().into()), ("agent", self.agent.clone().into())])
31 }
32 fn from_json(v: &Value) -> Result<Actor, String> {
33 Ok(Actor {
34 person: str_field(v, "person")?,
35 agent: match v.get("agent") {
36 None | Some(Value::Null) => None,
37 Some(a) => Some(a.as_str().ok_or("agent must be a string")?.to_string()),
38 },
39 })
40 }
41}
42
43impl std::fmt::Display for Actor {
44 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
45 match &self.agent {
46 Some(a) => write!(f, "{a} for {}", self.person),
47 None => write!(f, "{}", self.person),
48 }
49 }
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum ChangeState {
54 Idea,
55 Draft,
56 Proposed,
57 Landed,
58 Declined,
59 Withdrawn,
60}
61
62impl ChangeState {
63 pub fn name(self) -> &'static str {
64 match self {
65 ChangeState::Idea => "idea",
66 ChangeState::Draft => "draft",
67 ChangeState::Proposed => "proposed",
68 ChangeState::Landed => "landed",
69 ChangeState::Declined => "declined",
70 ChangeState::Withdrawn => "withdrawn",
71 }
72 }
73 pub fn parse(s: &str) -> Option<ChangeState> {
74 Some(match s {
75 "idea" => ChangeState::Idea,
76 "draft" => ChangeState::Draft,
77 "proposed" => ChangeState::Proposed,
78 "landed" => ChangeState::Landed,
79 "declined" => ChangeState::Declined,
80 "withdrawn" => ChangeState::Withdrawn,
81 _ => return None,
82 })
83 }
84 pub fn is_open(self) -> bool {
85 matches!(self, ChangeState::Idea | ChangeState::Draft | ChangeState::Proposed)
86 }
87}
88
89#[derive(Clone, Debug)]
90pub struct Person {
91 pub username: String,
92 pub display_name: String,
93 pub at: i64,
94}
95
96#[derive(Clone, Debug)]
97pub struct Agent {
98 pub id: String,
99 pub person: String,
100 pub name: String,
101 pub revoked: bool,
102 pub at: i64,
103}
104
105#[derive(Clone, Debug)]
106pub struct Point {
107 pub n: u64,
108 pub snapshot: Id,
109 pub message: String,
110 pub author: Actor,
111 pub landed_by: Actor,
112 pub change: Option<u64>,
113 pub step: Option<String>,
114 pub at: i64,
115}
116
117#[derive(Clone, Debug, PartialEq)]
118pub struct Step {
119 /// Stable within a change, across revisions.
120 pub id: String,
121 pub snapshot: Id,
122 pub message: String,
123}
124
125#[derive(Clone, Debug)]
126pub struct Revision {
127 pub number: u32,
128 pub base: u64,
129 pub steps: Vec<Step>,
130 pub note: Option<String>,
131 pub by: Actor,
132 pub at: i64,
133}
134
135#[derive(Clone, Debug)]
136pub struct Message {
137 pub id: u32,
138 pub body: String,
139 pub by: Actor,
140 pub at: i64,
141}
142
143#[derive(Clone, Debug)]
144pub struct Evidence {
145 pub revision: u32,
146 pub title: String,
147 pub media_type: String,
148 pub blob: Option<Id>,
149 pub url: Option<String>,
150 pub by: Actor,
151 pub at: i64,
152}
153
154/// Something that happened to a change, for its timeline.
155#[derive(Clone, Debug)]
156pub enum Event {
157 Opened,
158 Revised(u32),
159 State(ChangeState),
160 Said(u32),
161 Attached(usize),
162 Landed { revision: u32, points: (u64, u64) },
163}
164
165#[derive(Clone, Debug)]
166pub struct Change {
167 pub number: u64,
168 pub intent: String,
169 pub proposer: Actor,
170 pub state: ChangeState,
171 pub revisions: Vec<Revision>,
172 pub messages: Vec<Message>,
173 pub evidence: Vec<Evidence>,
174 pub timeline: Vec<(i64, Actor, Event)>,
175 pub at: i64,
176}
177
178impl Change {
179 pub fn latest(&self) -> Option<&Revision> {
180 self.revisions.last()
181 }
182}
183
184#[derive(Clone, Debug)]
185pub struct Project {
186 pub owner: String,
187 pub name: String,
188 pub public: bool,
189 pub points: Vec<Point>,
190 pub changes: Vec<Change>,
191 pub at: i64,
192}
193
194impl Project {
195 pub fn tip(&self) -> u64 {
196 self.points.len() as u64
197 }
198 pub fn point(&self, n: u64) -> Option<&Point> {
199 if n == 0 { None } else { self.points.get(n as usize - 1) }
200 }
201 pub fn change(&self, number: u64) -> Option<&Change> {
202 if number == 0 { None } else { self.changes.get(number as usize - 1) }
203 }
204 fn change_mut(&mut self, number: u64) -> Option<&mut Change> {
205 if number == 0 { None } else { self.changes.get_mut(number as usize - 1) }
206 }
207}
208
209#[derive(Default)]
210pub struct State {
211 pub people: Vec<Person>,
212 pub agents: Vec<Agent>,
213 pub projects: Vec<Project>,
214 pub records: u64,
215}
216
217// ── Reading fields ─────────────────────────────────────────
218
219fn str_field(v: &Value, key: &str) -> Result<String, String> {
220 v.get(key).and_then(Value::as_str).map(str::to_string).ok_or_else(|| format!("missing {key}"))
221}
222fn opt_str(v: &Value, key: &str) -> Result<Option<String>, String> {
223 match v.get(key) {
224 None | Some(Value::Null) => Ok(None),
225 Some(Value::Str(s)) => Ok(Some(s.clone())),
226 _ => Err(format!("{key} must be a string")),
227 }
228}
229fn int_field(v: &Value, key: &str) -> Result<i64, String> {
230 v.get(key).and_then(Value::as_i64).ok_or_else(|| format!("missing {key}"))
231}
232fn uint(v: &Value, key: &str) -> Result<u64, String> {
233 let n = int_field(v, key)?;
234 u64::try_from(n).map_err(|_| format!("{key} must not be negative"))
235}
236pub fn id_to_json(id: &Id) -> Value {
237 Value::Str(hex::encode(id))
238}
239fn id_value(v: &Value) -> Result<Id, String> {
240 let s = v.as_str().ok_or("an id must be a string")?;
241 let bytes = hex::decode(s).ok_or("an id must be hex")?;
242 bytes.try_into().map_err(|_| "an id must be 32 bytes".to_string())
243}
244fn id_field(v: &Value, key: &str) -> Result<Id, String> {
245 id_value(v.get(key).ok_or_else(|| format!("missing {key}"))?).map_err(|e| format!("{key}: {e}"))
246}
247fn opt_id(v: &Value, key: &str) -> Result<Option<Id>, String> {
248 match v.get(key) {
249 None | Some(Value::Null) => Ok(None),
250 _ => id_field(v, key).map(Some),
251 }
252}
253
254pub fn step_to_json(s: &Step) -> Value {
255 obj([("id", s.id.clone().into()), ("snapshot", id_to_json(&s.snapshot)), ("message", s.message.clone().into())])
256}
257fn step_from_json(v: &Value) -> Result<Step, String> {
258 Ok(Step { id: str_field(v, "id")?, snapshot: id_field(v, "snapshot")?, message: str_field(v, "message")? })
259}
260
261// ── Applying records ───────────────────────────────────────
262
263impl State {
264 pub fn person(&self, username: &str) -> Option<&Person> {
265 self.people.iter().find(|p| p.username == username)
266 }
267 pub fn agent(&self, id: &str) -> Option<&Agent> {
268 self.agents.iter().find(|a| a.id == id)
269 }
270 pub fn project(&self, owner: &str, name: &str) -> Option<&Project> {
271 self.projects.iter().find(|p| p.owner == owner && p.name == name)
272 }
273 fn project_mut(&mut self, owner: &str, name: &str) -> Result<&mut Project, String> {
274 self.projects.iter_mut().find(|p| p.owner == owner && p.name == name).ok_or_else(|| format!("no project {owner}/{name}"))
275 }
276
277 /// Fold one record into the state.
278 pub fn apply(&mut self, rec: &Value) -> Result<(), String> {
279 let t = str_field(rec, "t")?;
280 let at = int_field(rec, "at")?;
281 let by = Actor::from_json(rec.get("by").ok_or("missing by")?)?;
282 if t != "person" && self.person(&by.person).is_none() {
283 return Err(format!("unknown person {}", by.person));
284 }
285 if let Some(agent) = &by.agent {
286 match self.agent(agent) {
287 Some(a) if a.person == by.person && !a.revoked => {}
288 _ => return Err(format!("agent {agent} can't act for {}", by.person)),
289 }
290 }
291 match t.as_str() {
292 "person" => {
293 let username = str_field(rec, "username")?;
294 if username != by.person {
295 return Err("a person record is made by that person".into());
296 }
297 if self.person(&username).is_some() {
298 return Err(format!("person {username} exists"));
299 }
300 self.people.push(Person { display_name: str_field(rec, "display_name")?, username, at });
301 }
302 "agent" => {
303 let id = str_field(rec, "id")?;
304 if self.agent(&id).is_some() {
305 return Err(format!("agent {id} exists"));
306 }
307 self.agents.push(Agent { id, person: by.person.clone(), name: str_field(rec, "name")?, revoked: false, at });
308 }
309 "agent.revoke" => {
310 let id = str_field(rec, "id")?;
311 let a = self.agents.iter_mut().find(|a| a.id == id && a.person == by.person).ok_or("no such agent")?;
312 a.revoked = true;
313 }
314 "project" => {
315 let name = str_field(rec, "name")?;
316 if self.project(&by.person, &name).is_some() {
317 return Err(format!("project {}/{name} exists", by.person));
318 }
319 let public = matches!(rec.get("public"), Some(Value::Bool(true)));
320 self.projects.push(Project { owner: by.person.clone(), name, public, points: Vec::new(), changes: Vec::new(), at });
321 }
322 "point" => {
323 // A point recorded directly (the first snapshot of a project, or imported history).
324 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
325 if by.person != p.owner {
326 return Err("only the owner records points".into());
327 }
328 let n = uint(rec, "n")?;
329 if n != p.tip() + 1 {
330 return Err(format!("point {n} doesn't follow {}", p.tip()));
331 }
332 let author = match rec.get("author") {
333 Some(a) => Actor::from_json(a)?,
334 None => by.clone(),
335 };
336 p.points.push(Point { n, snapshot: id_field(rec, "snapshot")?, message: str_field(rec, "message")?, author, landed_by: by, change: None, step: None, at });
337 }
338 "change" => {
339 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
340 let number = uint(rec, "number")?;
341 if number != p.changes.len() as u64 + 1 {
342 return Err(format!("change {number} out of sequence"));
343 }
344 let state = ChangeState::parse(&str_field(rec, "state")?).ok_or("bad state")?;
345 if !matches!(state, ChangeState::Idea | ChangeState::Draft) {
346 return Err("a change opens as an idea or a draft".into());
347 }
348 p.changes.push(Change {
349 number,
350 intent: str_field(rec, "intent")?,
351 proposer: by.clone(),
352 state,
353 revisions: Vec::new(),
354 messages: Vec::new(),
355 evidence: Vec::new(),
356 timeline: vec![(at, by, Event::Opened)],
357 at,
358 });
359 }
360 "revision" => {
361 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
362 let tip = p.tip();
363 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
364 if !c.state.is_open() {
365 return Err("change is closed".into());
366 }
367 let number = uint(rec, "number")? as u32;
368 if number as usize != c.revisions.len() + 1 {
369 return Err("revision out of sequence".into());
370 }
371 let base = uint(rec, "base")?;
372 if base == 0 || base > tip {
373 return Err(format!("base {base} isn't a point"));
374 }
375 let steps = rec.get("steps").and_then(Value::as_array).ok_or("missing steps")?;
376 let steps = steps.iter().map(step_from_json).collect::<Result<Vec<_>, _>>()?;
377 if steps.is_empty() {
378 return Err("a revision needs at least one step".into());
379 }
380 c.revisions.push(Revision { number, base, steps, note: opt_str(rec, "note")?, by: by.clone(), at });
381 if c.state == ChangeState::Idea {
382 c.state = ChangeState::Draft;
383 }
384 c.timeline.push((at, by, Event::Revised(number)));
385 }
386 "state" => {
387 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
388 let owner = p.owner.clone();
389 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
390 let to = ChangeState::parse(&str_field(rec, "state")?).ok_or("bad state")?;
391 check_transition(c, to, &by, &owner)?;
392 c.state = to;
393 c.timeline.push((at, by, Event::State(to)));
394 }
395 "land" => {
396 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
397 if by.person != p.owner || by.agent.is_some() {
398 return Err("only the owner, in person, lands".into());
399 }
400 let number = uint(rec, "change")?;
401 let revision = uint(rec, "revision")? as u32;
402 let first = uint(rec, "first")?;
403 if first != p.tip() + 1 {
404 return Err("landing doesn't follow the tip".into());
405 }
406 let c = p.change(number).ok_or("no such change")?;
407 if c.state != ChangeState::Proposed {
408 return Err("only proposed changes land".into());
409 }
410 let rev = c.revisions.get((revision as usize).wrapping_sub(1)).ok_or("no such revision")?.clone();
411 let snapshots = rec.get("snapshots").and_then(Value::as_array).ok_or("missing snapshots")?;
412 let snapshots = snapshots.iter().map(id_value).collect::<Result<Vec<_>, _>>()?;
413 if snapshots.len() != rev.steps.len() {
414 return Err("one snapshot per step".into());
415 }
416 // Everything is checked; from here on nothing can fail.
417 for (i, (step, snapshot)) in rev.steps.iter().zip(snapshots).enumerate() {
418 p.points.push(Point {
419 n: first + i as u64,
420 snapshot,
421 message: step.message.clone(),
422 author: rev.by.clone(),
423 landed_by: by.clone(),
424 change: Some(number),
425 step: Some(step.id.clone()),
426 at,
427 });
428 }
429 let last = p.tip();
430 let c = p.change_mut(number).unwrap();
431 c.state = ChangeState::Landed;
432 c.timeline.push((at, by, Event::Landed { revision, points: (first, last) }));
433 }
434 "say" => {
435 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
436 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
437 let id = c.messages.len() as u32 + 1;
438 c.messages.push(Message { id, body: str_field(rec, "body")?, by: by.clone(), at });
439 c.timeline.push((at, by, Event::Said(id)));
440 }
441 "evidence" => {
442 let p = self.project_mut(&str_field(rec, "owner")?, &str_field(rec, "project")?)?;
443 let c = p.change_mut(uint(rec, "change")?).ok_or("no such change")?;
444 let revision = uint(rec, "revision")? as u32;
445 if revision == 0 || revision as usize > c.revisions.len() {
446 return Err("no such revision".into());
447 }
448 let blob = opt_id(rec, "blob")?;
449 let url = opt_str(rec, "url")?;
450 if blob.is_none() == url.is_none() {
451 return Err("evidence is a blob or a url".into());
452 }
453 c.evidence.push(Evidence { revision, title: str_field(rec, "title")?, media_type: str_field(rec, "type")?, blob, url, by: by.clone(), at });
454 let i = c.evidence.len() - 1;
455 c.timeline.push((at, by, Event::Attached(i)));
456 }
457 other => return Err(format!("unknown record type {other}")),
458 }
459 self.records += 1;
460 Ok(())
461 }
462}
463
464/// Who may move a change between which states.
465fn check_transition(c: &Change, to: ChangeState, by: &Actor, owner: &str) -> Result<(), String> {
466 use ChangeState::*;
467 let is_owner = by.person == owner;
468 // Decisions are the owner's own: an agent acting for them can't make them.
469 let owner_in_person = is_owner && by.agent.is_none();
470 let is_proposer = by.person == c.proposer.person;
471 match (c.state, to) {
472 (Draft, Proposed) if is_proposer || is_owner => Ok(()),
473 (Proposed, Draft) if is_proposer || is_owner => Ok(()),
474 (Idea | Draft | Proposed, Declined) if owner_in_person => Ok(()),
475 (Idea | Draft | Proposed, Withdrawn) if is_proposer => Ok(()),
476 (Declined | Withdrawn, Draft) if (is_proposer || is_owner) && !c.revisions.is_empty() => Ok(()),
477 (Declined | Withdrawn, Idea) if (is_proposer || is_owner) && c.revisions.is_empty() => Ok(()),
478 (from, to) if from == to => Err(format!("already {}", to.name())),
479 (from, to) => Err(format!("{} can't move a change from {} to {}", by, from.name(), to.name())),
480 }
481}