1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Duration, Utc};
11use myko::prelude::Eventable;
12use myko::wire::{MEvent, MEventType};
13
14use crate::entities::*;
15
16#[derive(Debug, Clone)]
19pub struct EventRecord {
20 pub id: String,
21 pub event: MEvent,
22}
23
24#[derive(Debug, Default)]
26pub struct World {
27 pub project: Option<Project>,
28 pub tasks: BTreeMap<String, Task>,
29 pub status_changes: Vec<StatusChange>,
31 pub deps: BTreeMap<String, Dependency>,
32 pub claims: BTreeMap<String, Claim>,
34 pub comments: Vec<Comment>,
36 pub commit_facts: BTreeMap<String, CommitFact>,
37 pub ref_facts: BTreeMap<String, RefFact>,
38}
39
40impl World {
41 pub fn changes_for(&self, task_id: &str) -> Vec<&StatusChange> {
43 self.status_changes
44 .iter()
45 .filter(|c| &*c.task_id == task_id)
46 .collect()
47 }
48
49 pub fn live_claim(&self, task_id: &str, now: DateTime<Utc>) -> Option<&Claim> {
51 let claim = self.claims.get(task_id)?;
52 let at = DateTime::parse_from_rfc3339(&claim.created)
53 .ok()?
54 .with_timezone(&Utc);
55 let ttl = Duration::seconds(i64::try_from(claim.ttl_secs).ok()?);
56 (at + ttl > now).then_some(claim)
57 }
58}
59
60pub fn materialize(mut records: Vec<EventRecord>) -> World {
61 records.sort_by(|a, b| {
62 (a.event.created_at.as_str(), a.id.as_str())
63 .cmp(&(b.event.created_at.as_str(), b.id.as_str()))
64 });
65
66 let mut w = World::default();
67 let mut status_changes: BTreeMap<String, StatusChange> = BTreeMap::new();
68 let mut comments: BTreeMap<String, Comment> = BTreeMap::new();
69
70 for r in &records {
71 let ev = &r.event;
72 let t = ev.item_type.as_str();
73 if t == Project::ENTITY_NAME_STATIC {
74 if matches!(ev.change_type, MEventType::SET)
75 && let Ok(p) = serde_json::from_value::<Project>(ev.item.clone())
76 {
77 w.project = Some(p);
78 }
79 } else if t == Task::ENTITY_NAME_STATIC {
80 apply(&mut w.tasks, ev);
81 } else if t == StatusChange::ENTITY_NAME_STATIC {
82 apply(&mut status_changes, ev);
83 } else if t == Dependency::ENTITY_NAME_STATIC {
84 apply(&mut w.deps, ev);
85 } else if t == Claim::ENTITY_NAME_STATIC {
86 apply(&mut w.claims, ev);
87 } else if t == Comment::ENTITY_NAME_STATIC {
88 apply(&mut comments, ev);
89 } else if t == CommitFact::ENTITY_NAME_STATIC {
90 apply(&mut w.commit_facts, ev);
91 } else if t == RefFact::ENTITY_NAME_STATIC {
92 apply(&mut w.ref_facts, ev);
93 }
94 }
96
97 w.status_changes = status_changes.into_values().collect();
98 w.status_changes
99 .sort_by(|a, b| (a.created.as_str(), &*a.id.0).cmp(&(b.created.as_str(), &*b.id.0)));
100 w.comments = comments.into_values().collect();
101 w.comments
102 .sort_by(|a, b| (a.created.as_str(), &*a.id.0).cmp(&(b.created.as_str(), &*b.id.0)));
103 w
104}
105
106fn apply<T>(map: &mut BTreeMap<String, T>, ev: &MEvent)
110where
111 T: Eventable,
112{
113 match ev.change_type {
114 MEventType::SET => {
115 if let Ok(item) = serde_json::from_value::<T>(ev.item.clone()) {
116 map.insert(item.id().to_string(), item);
117 }
118 }
119 MEventType::DEL => {
120 if let Some(id) = ev.item.get("id").and_then(|v| v.as_str()) {
121 map.remove(id);
122 }
123 }
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 fn rec<T: myko::prelude::Eventable>(oid: &str, item: &T, created_at: &str) -> EventRecord {
132 let mut event = MEvent::from_item(item, MEventType::SET, "m");
133 event.created_at = created_at.to_string();
134 EventRecord {
135 id: oid.to_string(),
136 event,
137 }
138 }
139
140 fn del<T: myko::prelude::Eventable>(oid: &str, item: &T, created_at: &str) -> EventRecord {
141 let mut event = MEvent::from_item(item, MEventType::DEL, "m");
142 event.created_at = created_at.to_string();
143 EventRecord {
144 id: oid.to_string(),
145 event,
146 }
147 }
148
149 fn task(id: &str, title: &str) -> Task {
150 Task {
151 id: id.into(),
152 project_id: "p".into(),
153 title: title.into(),
154 body: String::new(),
155 priority: Priority::default(),
156 labels: vec![],
157 created_by_dev: "d".into(),
158 created_by_machine: "m".into(),
159 created: "2026-07-01T00:00:00Z".into(),
160 }
161 }
162
163 #[test]
164 fn lww_by_created_at_regardless_of_arrival_order() {
165 let w = materialize(vec![
167 rec("bb", &task("t1", "newer title"), "2026-07-02T00:00:00Z"),
168 rec("aa", &task("t1", "older title"), "2026-07-01T00:00:00Z"),
169 ]);
170 assert_eq!(w.tasks["t1"].title, "newer title");
171 }
172
173 #[test]
174 fn created_at_tie_broken_by_event_id() {
175 let at = "2026-07-01T00:00:00Z";
176 let w = materialize(vec![
177 rec("ff", &task("t1", "id ff wins"), at),
178 rec("aa", &task("t1", "id aa loses"), at),
179 ]);
180 assert_eq!(w.tasks["t1"].title, "id ff wins");
181 }
182
183 #[test]
184 fn del_removes_dependency() {
185 let d = Dependency {
186 id: dependency_id("a", "b").into(),
187 project_id: "p".into(),
188 blocker_task_id: "a".into(),
189 blocked_task_id: "b".into(),
190 blocker_project_id: None,
191 blocker_ref: None,
192 via: None,
193 };
194 let w = materialize(vec![
195 rec("aa", &d, "2026-07-01T00:00:00Z"),
196 del("bb", &d, "2026-07-02T00:00:00Z"),
197 ]);
198 assert!(w.deps.is_empty());
199 }
200
201 #[test]
202 fn claim_liveness_respects_ttl() {
203 let c = Claim {
204 id: "t1".into(),
205 project_id: "p".into(),
206 task_id: "t1".into(),
207 dev: "d".into(),
208 machine: "m".into(),
209 worktree: "/w".into(),
210 machine_id: String::new(),
211 created: "2026-07-01T00:00:00Z".into(),
212 ttl_secs: 3600,
213 };
214 let w = materialize(vec![rec("aa", &c, "2026-07-01T00:00:00Z")]);
215 let live = "2026-07-01T00:30:00Z".parse::<DateTime<Utc>>().unwrap();
216 let dead = "2026-07-01T02:00:00Z".parse::<DateTime<Utc>>().unwrap();
217 assert!(w.live_claim("t1", live).is_some());
218 assert!(w.live_claim("t1", dead).is_none());
219 }
220
221 #[test]
222 fn project_and_sorted_append_only_sets() {
223 let p = Project {
224 id: "p".into(),
225 name: "levi".into(),
226 created: "2026-07-01T00:00:00Z".into(),
227 };
228 let c2 = Comment {
229 id: "c2".into(),
230 project_id: "p".into(),
231 task_id: "t1".into(),
232 body: "second".into(),
233 by_dev: "d".into(),
234 created: "2026-07-02T00:00:00Z".into(),
235 };
236 let c1 = Comment {
237 id: "c1".into(),
238 project_id: "p".into(),
239 task_id: "t1".into(),
240 body: "first".into(),
241 by_dev: "d".into(),
242 created: "2026-07-01T00:00:00Z".into(),
243 };
244 let w = materialize(vec![
245 rec("cc", &c2, "2026-07-02T00:00:00Z"),
246 rec("aa", &p, "2026-07-01T00:00:00Z"),
247 rec("bb", &c1, "2026-07-01T00:00:00Z"),
248 ]);
249 assert_eq!(w.project.as_ref().unwrap().name, "levi");
250 let bodies: Vec<_> = w.comments.iter().map(|c| c.body.as_str()).collect();
251 assert_eq!(bodies, ["first", "second"]);
252 }
253
254 #[test]
255 fn unknown_item_type_is_ignored() {
256 let mut event = MEvent::from_item(&task("t1", "x"), MEventType::SET, "m");
257 event.item_type = "FutureThing".into();
258 let w = materialize(vec![EventRecord {
259 id: "aa".into(),
260 event,
261 }]);
262 assert!(w.tasks.is_empty());
263 }
264}