Skip to main content

levi_core/
hub.rs

1//! Hub-side machinery: unwrap incoming LogEntries into real entities so
2//! aggregate queries and the dashboard see Tasks/StatusChanges/etc.
3//! (spec §Sync, hub leg).
4//!
5//! Note this command never inspects entity types itself — `emit_event_batch`
6//! routes by `item_type` exactly like direct ingestion. The command exists
7//! because the hub keeps the *journal* (LogEntries carry the original
8//! content-addressed bytes the pull leg needs), and because it is the
9//! interposition point for LWW ordering: myko applies raw events in arrival
10//! order, so without the [`Applied`] guard an event synced late would
11//! clobber newer state that every CLI (which sorts the full log before
12//! replay) resolves correctly.
13
14use std::sync::Arc;
15
16use myko::command::{CommandContext, CommandError, CommandHandler};
17use myko::prelude::*;
18use myko::saga::{SagaContext, SagaHandler};
19use myko::wire::MEventType;
20
21use crate::entities::{
22    Applied, GetAppliedsByIds, GetLogEntrysByQuery, LogEntry, PartialLogEntry, applied_id,
23};
24
25/// Apply the event wrapped inside a LogEntry to this node's stores, unless a
26/// newer event for the same entity was already applied (LWW by
27/// (created_at, event id), the same key `materialize()` sorts by).
28/// Idempotent: re-applying converges.
29#[myko_command]
30pub struct ApplyLogEntry {
31    pub cbor_b64: String,
32    /// The LogEntry id — the event's content address (git blob OID).
33    pub event_oid: String,
34}
35
36impl CommandHandler for ApplyLogEntry {
37    fn execute(self, ctx: CommandContext) -> Result<(), CommandError> {
38        use base64::Engine as _;
39        let err = |m: String| CommandError {
40            tx: ctx.tx().to_string(),
41            command_id: ctx.command_id.to_string(),
42            message: m,
43        };
44        let bytes = base64::engine::general_purpose::STANDARD
45            .decode(&self.cbor_b64)
46            .map_err(|e| err(format!("invalid base64 in LogEntry: {e}")))?;
47        let event: myko::wire::MEvent = ciborium::from_reader(bytes.as_slice())
48            .map_err(|e| err(format!("invalid CBOR in LogEntry: {e}")))?;
49        // Never let a wrapped LogEntry apply another LogEntry: no recursion.
50        if event.item_type == LogEntry::ENTITY_NAME_STATIC {
51            return Err(err("refusing to unwrap a nested LogEntry".into()));
52        }
53        let Some(item_id) = event.item.get("id").and_then(|v| v.as_str()) else {
54            return Err(err("wrapped event has no item id".into()));
55        };
56
57        // LWW guard: skip events older than what this entity already shows.
58        let guard_id = applied_id(&event.item_type, item_id);
59        let existing = ctx.exec_query_first(GetAppliedsByIds {
60            ids: vec![guard_id.clone().into()],
61        })?;
62        let incoming = (event.created_at.clone(), self.event_oid.clone());
63        if let Some(applied) = existing
64            && (applied.event_created.as_str(), applied.event_oid.as_str())
65                > (incoming.0.as_str(), incoming.1.as_str())
66        {
67            return Ok(()); // stale: a newer event was already applied
68        }
69
70        ctx.emit_event_batch(vec![event])?;
71        ctx.emit_set(&Applied {
72            id: guard_id.into(),
73            event_created: incoming.0,
74            event_oid: incoming.1,
75        })?;
76        Ok(())
77    }
78}
79
80myko::register_command_handler!(ApplyLogEntry);
81
82/// Saga: every LogEntry SET (from CLI pushes or Postgres replay) is unwrapped
83/// into its inner event.
84#[myko_saga]
85pub struct UnwrapLogEntries;
86
87impl SagaHandler for UnwrapLogEntries {
88    type EventItem = LogEntry;
89    type Command = ApplyLogEntry;
90    const EVENT_TYPE: MEventType = MEventType::SET;
91
92    fn handle(
93        item: LogEntry,
94        _event: myko::wire::MEvent,
95        _ctx: Arc<SagaContext>,
96    ) -> Option<ApplyLogEntry> {
97        Some(ApplyLogEntry {
98            cbor_b64: item.cbor_b64,
99            event_oid: item.id.to_string(),
100        })
101    }
102}
103
104/// Bucketed hash summary of a project's LogEntry ids — the hub side of the
105/// Merkle-style sync comparison (see `crate::merkle`). Reactive: recomputes
106/// as entries arrive.
107#[myko_report_output]
108pub struct LogEntryBucketsOut {
109    pub buckets: std::collections::BTreeMap<String, String>,
110}
111
112#[myko_report(LogEntryBucketsOut)]
113pub struct LogEntryBuckets {
114    pub project_id: String,
115}
116
117impl myko::report::ReportHandler for LogEntryBuckets {
118    type Output = LogEntryBucketsOut;
119
120    #[cfg(not(target_arch = "wasm32"))]
121    fn compute(
122        &self,
123        ctx: myko::report::ReportContext,
124    ) -> impl myko::hyphae::MaterializeDefinite<Arc<Self::Output>> {
125        ctx.query_map(GetLogEntrysByQuery(PartialLogEntry {
126            project_id: Some(self.project_id.clone()),
127            ..Default::default()
128        }))
129        .items()
130        .map(|entries| {
131            Arc::new(LogEntryBucketsOut {
132                buckets: crate::merkle::bucket_hashes(entries.iter().map(|e| &*e.id.0)),
133            })
134        })
135    }
136}
137
138/// The ids inside one bucket — fetched only for buckets whose hashes differ.
139#[myko_report_output]
140pub struct LogEntryBucketIdsOut {
141    pub ids: Vec<String>,
142}
143
144#[myko_report(LogEntryBucketIdsOut)]
145pub struct LogEntryBucketIds {
146    pub project_id: String,
147    pub bucket: String,
148}
149
150impl myko::report::ReportHandler for LogEntryBucketIds {
151    type Output = LogEntryBucketIdsOut;
152
153    #[cfg(not(target_arch = "wasm32"))]
154    fn compute(
155        &self,
156        ctx: myko::report::ReportContext,
157    ) -> impl myko::hyphae::MaterializeDefinite<Arc<Self::Output>> {
158        let bucket = self.bucket.clone();
159        ctx.query_map(GetLogEntrysByQuery(PartialLogEntry {
160            project_id: Some(self.project_id.clone()),
161            ..Default::default()
162        }))
163        .items()
164        .map(move |entries| {
165            let mut ids: Vec<String> = entries
166                .iter()
167                .map(|e| e.id.to_string())
168                .filter(|id| id.starts_with(&bucket))
169                .collect();
170            ids.sort_unstable();
171            Arc::new(LogEntryBucketIdsOut { ids })
172        })
173    }
174}