Skip to main content

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}