Skip to main content

fret_runtime/
action_payload.rs

1use std::any::Any;
2use std::collections::HashMap;
3
4use fret_core::AppWindowId;
5
6use crate::{ActionId, TickId};
7
8#[derive(Debug)]
9struct PendingActionPayloadV1 {
10    tick_id: TickId,
11    window: AppWindowId,
12    action: ActionId,
13    payload: Box<dyn Any + Send + Sync>,
14}
15
16/// Window-scoped, tick-local payload store for parameterized actions (v2 prototype).
17///
18/// This is intentionally best-effort and transient:
19/// - callers should record a payload immediately before dispatching an `ActionId`,
20/// - handlers should consume the payload at dispatch time (or treat missing payload as not handled),
21/// - entries expire after a small tick TTL.
22///
23/// See ADR 0312.
24#[derive(Default)]
25pub struct WindowPendingActionPayloadService {
26    per_window: HashMap<AppWindowId, Vec<PendingActionPayloadV1>>,
27}
28
29impl WindowPendingActionPayloadService {
30    const MAX_PENDING_PER_WINDOW: usize = 32;
31    const PENDING_TTL_TICKS: u64 = 64;
32
33    pub fn record(
34        &mut self,
35        window: AppWindowId,
36        tick_id: TickId,
37        action: ActionId,
38        payload: Box<dyn Any + Send + Sync>,
39    ) {
40        let pending = PendingActionPayloadV1 {
41            tick_id,
42            window,
43            action,
44            payload,
45        };
46        let entries = self.per_window.entry(window).or_default();
47        entries.push(pending);
48        if entries.len() > Self::MAX_PENDING_PER_WINDOW {
49            let extra = entries.len().saturating_sub(Self::MAX_PENDING_PER_WINDOW);
50            entries.drain(0..extra);
51        }
52    }
53
54    pub fn consume(
55        &mut self,
56        window: AppWindowId,
57        tick_id: TickId,
58        action: &ActionId,
59    ) -> Option<Box<dyn Any + Send + Sync>> {
60        let entries = self.per_window.get_mut(&window)?;
61
62        // Drop stale pending entries.
63        //
64        // Like `WindowPendingCommandDispatchSourceService`, payload is best-effort: the actual
65        // command dispatch may be handled on a later tick (effect flushing deferral, scheduled
66        // work). Keep a small TTL window to preserve usability without making this a durable
67        // storage mechanism.
68        let min_tick = TickId(tick_id.0.saturating_sub(Self::PENDING_TTL_TICKS));
69        entries.retain(|e| e.tick_id.0 >= min_tick.0 && e.tick_id.0 <= tick_id.0);
70
71        let pos = entries
72            .iter()
73            .rposition(|e| &e.action == action && e.window == window)?;
74        Some(entries.remove(pos).payload)
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn pending_payload_expires_across_ticks() {
84        let mut svc = WindowPendingActionPayloadService::default();
85        let window = AppWindowId::default();
86        let action = ActionId::from("test.payload_action");
87
88        svc.record(
89            window,
90            TickId(10),
91            action.clone(),
92            Box::new(123u32) as Box<dyn Any + Send + Sync>,
93        );
94
95        assert!(svc.consume(window, TickId(10), &action).is_some());
96
97        svc.record(
98            window,
99            TickId(10),
100            action.clone(),
101            Box::new(456u32) as Box<dyn Any + Send + Sync>,
102        );
103
104        // TTL is 64 ticks: at tick 10 + 65, the earlier entry should be stale.
105        assert!(svc.consume(window, TickId(75), &action).is_none());
106    }
107
108    #[test]
109    fn pending_payload_consumes_most_recent() {
110        let mut svc = WindowPendingActionPayloadService::default();
111        let window = AppWindowId::default();
112        let action = ActionId::from("test.payload_action");
113
114        svc.record(
115            window,
116            TickId(10),
117            action.clone(),
118            Box::new(1u32) as Box<dyn Any + Send + Sync>,
119        );
120        svc.record(
121            window,
122            TickId(11),
123            action.clone(),
124            Box::new(2u32) as Box<dyn Any + Send + Sync>,
125        );
126
127        let payload = svc
128            .consume(window, TickId(11), &action)
129            .expect("payload must exist");
130        let payload = payload.downcast::<u32>().expect("type must match");
131        assert_eq!(*payload, 2);
132    }
133}