aura_composition/view_delta.rs
1//! View Delta Reduction Infrastructure
2//!
3//! This module provides extensible view-level reduction for turning journal facts
4//! into application/UI-level deltas. Domain crates register their view reducers,
5//! and the runtime scheduler uses the registry to dispatch facts appropriately.
6//!
7//! # Architecture
8//!
9//! View delta reduction is separate from journal-level reduction (`FactReducer`):
10//! - **Journal reduction** (`aura-journal`): Facts → `RelationalBinding` for storage
11//! - **View reduction** (this module): Facts → View Deltas for UI updates
12//!
13//! # Pattern
14//!
15//! Domain crates export:
16//! 1. Fact type implementing `DomainFact` (in their crate)
17//! 2. Delta type for view updates (e.g., `ChatDelta`)
18//! 3. View reducer implementing `ViewDeltaReducer`
19//!
20//! # Example
21//!
22//! ```ignore
23//! // In aura-chat/src/view.rs:
24//! #[derive(Debug, Clone)]
25//! pub enum ChatDelta {
26//! ChannelAdded { id: String, name: String },
27//! MessageAdded { channel_id: String, content: String },
28//! }
29//!
30//! pub struct ChatViewReducer;
31//!
32//! impl ViewDeltaReducer for ChatViewReducer {
33//! fn handles_type(&self) -> &'static str { "chat" }
34//!
35//! fn reduce_fact(
36//! &self,
37//! binding_type: &str,
38//! binding_data: &[u8],
39//! _own_authority: Option<AuthorityId>,
40//! ) -> Vec<ViewDelta> {
41//! if binding_type != "chat" { return vec![]; }
42//! ChatFact::from_bytes(binding_data)
43//! .map(|fact| ChatDelta::from(fact).into())
44//! .into_iter()
45//! .flatten()
46//! .collect()
47//! }
48//! }
49//!
50//! // Registration at runtime (in aura-agent):
51//! registry.register("chat", Box::new(ChatViewReducer));
52//! ```
53
54use aura_core::types::identifiers::AuthorityId;
55use std::any::Any;
56use std::collections::HashMap;
57use std::fmt::Debug;
58
59/// Type-erased view delta that can hold any domain-specific delta type.
60///
61/// Domain crates wrap their concrete delta types in this for the registry.
62pub type ViewDelta = Box<dyn Any + Send + Sync>;
63
64/// Trait for reducing journal facts to view deltas.
65///
66/// Domain crates implement this to define how their facts are transformed
67/// into view-level deltas for UI updates.
68pub trait ViewDeltaReducer: Send + Sync {
69 /// Returns the fact type ID this reducer handles.
70 ///
71 /// This should match the `type_id()` from `DomainFact`.
72 fn handles_type(&self) -> &'static str;
73
74 /// Reduce a serialized fact to view deltas.
75 ///
76 /// # Arguments
77 /// * `binding_type` - The type identifier from `RelationalFact::Generic`
78 /// * `binding_data` - The serialized fact data
79 /// * `own_authority` - The current user's authority ID for contextual reduction.
80 /// For example, determining inbound vs outbound invitations.
81 ///
82 /// # Returns
83 /// A vector of view deltas. Returns empty if the binding type doesn't match
84 /// or if reduction fails.
85 fn reduce_fact(
86 &self,
87 binding_type: &str,
88 binding_data: &[u8],
89 own_authority: Option<AuthorityId>,
90 ) -> Vec<ViewDelta>;
91}
92
93/// Trait for deltas that can be losslessly (or intentionally) compacted.
94///
95/// The compaction behavior is defined by `try_merge`, which should preserve
96/// the effective outcome of applying the two deltas in-order.
97pub trait ComposableDelta: Sized {
98 /// Key used to determine whether two deltas are merge candidates.
99 type Key: PartialEq;
100
101 /// Return a key that identifies the logical target of this delta.
102 fn key(&self) -> Self::Key;
103
104 /// Attempt to merge `other` into `self`.
105 ///
106 /// Returns `true` if `other` was merged and can be discarded.
107 /// Returns `false` if the deltas must remain separate.
108 fn try_merge(&mut self, other: Self) -> bool;
109}
110
111/// Compact deltas while preserving relative order.
112///
113/// This is an order-aware compactor: it only merges with the most recent prior
114/// delta for the same key, preserving sequential semantics.
115pub fn compact_deltas<T: ComposableDelta + Clone>(deltas: Vec<T>) -> Vec<T> {
116 let mut output: Vec<T> = Vec::with_capacity(deltas.len());
117
118 for delta in deltas {
119 let key = delta.key();
120 if let Some(pos) = output.iter().rposition(|existing| existing.key() == key) {
121 let mut existing = output.remove(pos);
122 if existing.try_merge(delta.clone()) {
123 output.insert(pos, existing);
124 continue;
125 }
126 output.insert(pos, existing);
127 }
128 output.push(delta);
129 }
130
131 output
132}
133
134/// Registry for domain view reducers.
135///
136/// The runtime scheduler uses this to dispatch facts to appropriate reducers.
137#[derive(Default)]
138pub struct ViewDeltaRegistry {
139 /// Map from type_id string to reducer
140 reducers: HashMap<String, Box<dyn ViewDeltaReducer>>,
141}
142
143impl ViewDeltaRegistry {
144 /// Create a new empty registry.
145 pub fn new() -> Self {
146 Self::default()
147 }
148
149 /// Register a view delta reducer for a fact type.
150 ///
151 /// # Arguments
152 /// * `type_id` - The fact type identifier (e.g., "chat", "invitation")
153 /// * `reducer` - The reducer that handles this fact type
154 pub fn register(&mut self, type_id: &str, reducer: Box<dyn ViewDeltaReducer>) {
155 self.reducers.insert(type_id.to_string(), reducer);
156 }
157
158 /// Check if a type_id has a registered reducer.
159 pub fn is_registered(&self, type_id: &str) -> bool {
160 self.reducers.contains_key(type_id)
161 }
162
163 /// Get the reducer for a given type_id.
164 pub fn get_reducer(&self, type_id: &str) -> Option<&dyn ViewDeltaReducer> {
165 self.reducers.get(type_id).map(|r| r.as_ref())
166 }
167
168 /// Reduce a fact using the appropriate registered reducer.
169 ///
170 /// # Arguments
171 /// * `binding_type` - The fact type identifier
172 /// * `binding_data` - The serialized fact data
173 /// * `own_authority` - The current user's authority for contextual reduction
174 ///
175 /// If no reducer is registered for the binding_type, returns empty.
176 pub fn reduce(
177 &self,
178 binding_type: &str,
179 binding_data: &[u8],
180 own_authority: Option<AuthorityId>,
181 ) -> Vec<ViewDelta> {
182 if let Some(reducer) = self.reducers.get(binding_type) {
183 reducer.reduce_fact(binding_type, binding_data, own_authority)
184 } else {
185 Vec::new()
186 }
187 }
188
189 /// Get all registered type IDs.
190 pub fn registered_types(&self) -> impl Iterator<Item = &str> {
191 self.reducers.keys().map(|s| s.as_str())
192 }
193}
194
195impl Debug for ViewDeltaRegistry {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 f.debug_struct("ViewDeltaRegistry")
198 .field(
199 "registered_types",
200 &self.reducers.keys().collect::<Vec<_>>(),
201 )
202 .finish()
203 }
204}
205
206/// Helper trait for domain crates to convert their deltas to ViewDelta.
207///
208/// This provides a convenient way to box domain deltas.
209pub trait IntoViewDelta: Any + Send + Sync + Sized {
210 /// Convert self into a type-erased ViewDelta.
211 fn into_view_delta(self) -> ViewDelta {
212 Box::new(self)
213 }
214}
215
216// Blanket implementation for all compatible types
217impl<T: Any + Send + Sync + Sized> IntoViewDelta for T {}
218
219/// Helper to downcast a ViewDelta back to a concrete type.
220///
221/// # Example
222/// ```ignore
223/// let delta: ViewDelta = ChatDelta::ChannelAdded { ... }.into_view_delta();
224/// if let Some(chat_delta) = downcast_delta::<ChatDelta>(&delta) {
225/// // Use chat_delta
226/// }
227/// ```
228pub fn downcast_delta<T: 'static>(delta: &ViewDelta) -> Option<&T> {
229 delta.downcast_ref::<T>()
230}
231
232/// Helper to downcast and take ownership of a ViewDelta.
233pub fn downcast_delta_owned<T: 'static>(delta: ViewDelta) -> Option<T> {
234 delta.downcast::<T>().ok().map(|b| *b)
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 // Test delta type
242 #[derive(Debug, Clone, PartialEq)]
243 enum TestDelta {
244 ItemAdded { id: String },
245 ItemRemoved { id: String },
246 }
247
248 impl ComposableDelta for TestDelta {
249 type Key = String;
250
251 fn key(&self) -> Self::Key {
252 match self {
253 TestDelta::ItemAdded { id } | TestDelta::ItemRemoved { id } => id.clone(),
254 }
255 }
256
257 fn try_merge(&mut self, other: Self) -> bool {
258 match (self, other) {
259 (TestDelta::ItemAdded { id }, TestDelta::ItemAdded { id: other_id }) => {
260 *id = other_id;
261 true
262 }
263 (TestDelta::ItemRemoved { id }, TestDelta::ItemRemoved { id: other_id }) => {
264 *id = other_id;
265 true
266 }
267 _ => false,
268 }
269 }
270 }
271
272 // Test reducer
273 struct TestReducer;
274
275 impl ViewDeltaReducer for TestReducer {
276 fn handles_type(&self) -> &'static str {
277 "test"
278 }
279
280 fn reduce_fact(
281 &self,
282 binding_type: &str,
283 binding_data: &[u8],
284 _own_authority: Option<AuthorityId>,
285 ) -> Vec<ViewDelta> {
286 if binding_type != "test" {
287 return vec![];
288 }
289
290 // Simple: treat binding_data as an ID string
291 if let Ok(id) = std::str::from_utf8(binding_data) {
292 vec![TestDelta::ItemAdded { id: id.to_string() }.into_view_delta()]
293 } else {
294 vec![]
295 }
296 }
297 }
298
299 /// Compaction merges same-key deltas and preserves distinct keys.
300 #[test]
301 fn test_compact_deltas_merges_by_key() {
302 let deltas = vec![
303 TestDelta::ItemAdded {
304 id: "a".to_string(),
305 },
306 TestDelta::ItemAdded {
307 id: "a".to_string(),
308 },
309 TestDelta::ItemRemoved {
310 id: "b".to_string(),
311 },
312 TestDelta::ItemRemoved {
313 id: "b".to_string(),
314 },
315 ];
316
317 let compacted = compact_deltas(deltas);
318 assert_eq!(
319 compacted,
320 vec![
321 TestDelta::ItemAdded {
322 id: "a".to_string()
323 },
324 TestDelta::ItemRemoved {
325 id: "b".to_string()
326 },
327 ]
328 );
329 }
330
331 /// Registered type is discoverable; unregistered types are not.
332 #[test]
333 fn test_registry_registration() {
334 let mut registry = ViewDeltaRegistry::new();
335 registry.register("test", Box::new(TestReducer));
336
337 assert!(registry.is_registered("test"));
338 assert!(!registry.is_registered("unknown"));
339 }
340
341 /// Reduce dispatches to the registered reducer and produces the correct delta.
342 #[test]
343 fn test_registry_reduce() {
344 let mut registry = ViewDeltaRegistry::new();
345 registry.register("test", Box::new(TestReducer));
346
347 let deltas = registry.reduce("test", b"item123", None);
348 assert_eq!(deltas.len(), 1);
349
350 let delta = downcast_delta::<TestDelta>(&deltas[0]).unwrap();
351 assert_eq!(
352 delta,
353 &TestDelta::ItemAdded {
354 id: "item123".to_string()
355 }
356 );
357 }
358
359 /// Reducing an unregistered type returns empty — no panic, no fallback.
360 #[test]
361 fn test_registry_reduce_unknown_type() {
362 let registry = ViewDeltaRegistry::new();
363 let deltas = registry.reduce("unknown", b"data", None);
364 assert!(deltas.is_empty());
365 }
366
367 /// Type-erased ViewDelta round-trips through `into_view_delta` and `downcast_delta`.
368 #[test]
369 fn test_into_view_delta() {
370 let delta = TestDelta::ItemRemoved {
371 id: "xyz".to_string(),
372 };
373 let view_delta = delta.clone().into_view_delta();
374
375 let recovered = downcast_delta::<TestDelta>(&view_delta).unwrap();
376 assert_eq!(recovered, &delta);
377 }
378
379 /// `downcast_delta_owned` takes ownership and recovers the original value.
380 #[test]
381 fn test_downcast_owned() {
382 let delta = TestDelta::ItemAdded {
383 id: "abc".to_string(),
384 };
385 let view_delta = delta.clone().into_view_delta();
386
387 let recovered = downcast_delta_owned::<TestDelta>(view_delta).unwrap();
388 assert_eq!(recovered, delta);
389 }
390}