1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6 ApplicationSemanticModel, Effect, EffectExecutionPolicy, EffectRenderBoundary,
7 EffectValidation, ExecutionBoundary, RuntimeEffectRegistry, SemanticId, SourceProvenance,
8};
9
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[serde(transparent)]
16pub struct EffectActivationSlotId(String);
17
18impl EffectActivationSlotId {
19 #[must_use]
20 pub fn for_effect(effect: &Effect) -> Option<Self> {
21 effect.owner.entity_id().map(|component| {
22 Self(
23 component
24 .effect_activation_slot(&effect.name)
25 .as_str()
26 .to_string(),
27 )
28 })
29 }
30
31 #[must_use]
32 pub fn as_str(&self) -> &str {
33 &self.0
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum EffectActivationStatus {
41 Pending,
42 Completed,
43 Failed,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct EffectInitialResumeMembership {
49 pub render_boundary: EffectRenderBoundary,
50 pub batch_index: u32,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct EffectResumeRecord {
56 pub effect: SemanticId,
57 pub activation_slot: Option<EffectActivationSlotId>,
58 pub initial_status: Option<EffectActivationStatus>,
59 pub execution_policy: EffectExecutionPolicy,
60 pub runtime_function: SemanticId,
61 pub initial_plan_membership: Option<EffectInitialResumeMembership>,
62 pub action_batches: Vec<SemanticId>,
63 pub boundary: ExecutionBoundary,
64 pub provenance: SourceProvenance,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub struct EffectResumePlan {
70 pub records: Vec<EffectResumeRecord>,
71}
72
73impl EffectResumePlan {
74 #[must_use]
75 pub fn record(&self, effect: &SemanticId) -> Option<&EffectResumeRecord> {
76 self.records.iter().find(|record| &record.effect == effect)
77 }
78
79 #[must_use]
80 pub fn activation_slot(&self, effect: &SemanticId) -> Option<&EffectActivationSlotId> {
81 self.record(effect)
82 .and_then(|record| record.activation_slot.as_ref())
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct EffectResumeValidationDiagnostic {
88 pub code: String,
89 pub message: String,
90}
91
92#[must_use]
96pub fn build_effect_resume_plan(
97 model: &ApplicationSemanticModel,
98 registry: &RuntimeEffectRegistry,
99) -> EffectResumePlan {
100 let records = registry
101 .records
102 .iter()
103 .filter_map(|(effect_id, runtime)| {
104 let effect = model.effects.get(effect_id)?;
105 (effect.validation == EffectValidation::Valid).then_some((effect, runtime))
106 })
107 .filter_map(|(effect, runtime)| {
108 let initial_plan_membership =
109 runtime
110 .initial_trigger
111 .as_ref()
112 .map(|trigger| EffectInitialResumeMembership {
113 render_boundary: trigger.render_boundary,
114 batch_index: trigger.effect_batch_index,
115 });
116 let activation_slot = initial_plan_membership
117 .as_ref()
118 .and_then(|_| EffectActivationSlotId::for_effect(effect));
119 let action_batches = runtime
120 .action_batch_triggers
121 .iter()
122 .map(|trigger| trigger.action_batch.clone())
123 .collect::<BTreeSet<_>>()
124 .into_iter()
125 .collect::<Vec<_>>();
126 (initial_plan_membership.is_some() || !action_batches.is_empty()).then_some(
127 EffectResumeRecord {
128 effect: effect.id.clone(),
129 initial_status: activation_slot
130 .as_ref()
131 .map(|_| EffectActivationStatus::Pending),
132 activation_slot,
133 execution_policy: effect.execution_policy,
134 runtime_function: runtime.execution_function.clone(),
135 initial_plan_membership,
136 action_batches,
137 boundary: runtime.execution_boundary,
138 provenance: effect.provenance.clone(),
139 },
140 )
141 })
142 .collect();
143 EffectResumePlan { records }
144}
145
146#[must_use]
148#[allow(clippy::too_many_lines)]
149pub fn validate_effect_resume_plan(
150 model: &ApplicationSemanticModel,
151 registry: &RuntimeEffectRegistry,
152 plan: &EffectResumePlan,
153) -> Vec<EffectResumeValidationDiagnostic> {
154 let mut diagnostics = Vec::new();
155 let mut effects = BTreeSet::new();
156 let mut slots = BTreeSet::new();
157
158 for record in &plan.records {
159 if !effects.insert(record.effect.clone()) {
160 diagnostics.push(diagnostic(
161 "PSRSM1101",
162 "effect resume record is duplicated",
163 ));
164 }
165 let Some(effect) = model.effects.get(&record.effect) else {
166 diagnostics.push(diagnostic(
167 "PSRSM1102",
168 "effect resume record has no canonical effect",
169 ));
170 continue;
171 };
172 let Some(runtime) = registry.record(&record.effect) else {
173 diagnostics.push(diagnostic(
174 "PSRSM1103",
175 "effect resume record has no runtime effect record",
176 ));
177 continue;
178 };
179 if effect.validation != EffectValidation::Valid {
180 diagnostics.push(diagnostic(
181 "PSRSM1104",
182 "invalid effect has a resume record",
183 ));
184 }
185 if record.runtime_function != runtime.execution_function
186 || record.runtime_function.as_str().is_empty()
187 {
188 diagnostics.push(diagnostic(
189 "PSRSM1105",
190 "effect resume record has no canonical runtime function",
191 ));
192 }
193 if record.execution_policy != effect.execution_policy
194 || record.boundary != runtime.execution_boundary
195 {
196 diagnostics.push(diagnostic(
197 "PSRSM1106",
198 "effect resume record mismatches canonical execution facts",
199 ));
200 }
201 let expected_initial =
202 runtime
203 .initial_trigger
204 .as_ref()
205 .map(|trigger| EffectInitialResumeMembership {
206 render_boundary: trigger.render_boundary,
207 batch_index: trigger.effect_batch_index,
208 });
209 if record.initial_plan_membership != expected_initial {
210 diagnostics.push(diagnostic(
211 "PSRSM1107",
212 "effect resume initial membership mismatches F9",
213 ));
214 }
215 let expected_slots = expected_initial
216 .as_ref()
217 .and_then(|_| EffectActivationSlotId::for_effect(effect));
218 if record.activation_slot != expected_slots {
219 diagnostics.push(diagnostic(
220 "PSRSM1108",
221 "effect activation slot does not match initial membership",
222 ));
223 }
224 if record.initial_status
225 != record
226 .activation_slot
227 .as_ref()
228 .map(|_| EffectActivationStatus::Pending)
229 {
230 diagnostics.push(diagnostic(
231 "PSRSM1109",
232 "effect activation status must be pending exactly when a slot exists",
233 ));
234 }
235 if let Some(slot) = &record.activation_slot {
236 if !slots.insert(slot.clone()) {
237 diagnostics.push(diagnostic(
238 "PSRSM1110",
239 "effect activation slot is duplicated",
240 ));
241 }
242 }
243 let expected_batches = runtime
244 .action_batch_triggers
245 .iter()
246 .map(|trigger| trigger.action_batch.clone())
247 .collect::<BTreeSet<_>>()
248 .into_iter()
249 .collect::<Vec<_>>();
250 if record.action_batches != expected_batches
251 || record
252 .action_batches
253 .iter()
254 .any(|batch| !model.effect_trigger_plan.action_batches.contains_key(batch))
255 {
256 diagnostics.push(diagnostic(
257 "PSRSM1111",
258 "effect resume action batches are not canonical F8 references",
259 ));
260 }
261 }
262
263 diagnostics
264}
265
266fn diagnostic(code: &str, message: &str) -> EffectResumeValidationDiagnostic {
267 EffectResumeValidationDiagnostic {
268 code: code.to_string(),
269 message: message.to_string(),
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use crate::{
276 build_application_semantic_model, build_effect_resume_plan, build_runtime_effect_registry,
277 lower_components_to_ir, optimize_effect_ir, validate_effect_resume_plan,
278 EffectActivationStatus,
279 };
280
281 #[test]
282 fn plans_stable_initial_slots_and_canonical_action_batches() {
283 let parsed = presolve_parser::parse_file(
284 "src/EffectResume.tsx",
285 r#"
286@component("x-effect-resume")
287class EffectResume extends Component {
288 count = state(1);
289 label = state("ready");
290
291 @computed()
292 get doubled() { return this.count * 2; }
293
294 @action()
295 increment() { this.count += 1; }
296
297 @action()
298 rename() { this.label = "done"; }
299
300 @effect()
301 sync() { console.log(this.doubled); console.log(this.label); }
302
303 @effect()
304 ready() { console.log("ready"); }
305
306 @effect()
307 invalid() { this.count = 0; }
308}
309"#,
310 );
311 let model = build_application_semantic_model(&parsed);
312 let registry = build_runtime_effect_registry(
313 &model,
314 &optimize_effect_ir(&lower_components_to_ir(&model)).output,
315 );
316 let plan = build_effect_resume_plan(&model, ®istry);
317 let sync = model.components[0].id.effect("sync");
318 let ready = model.components[0].id.effect("ready");
319 let invalid = model.components[0].id.effect("invalid");
320 let sync_record = plan.record(&sync).expect("sync resume record");
321 let ready_record = plan.record(&ready).expect("ready resume record");
322
323 assert_eq!(plan.records.len(), 2);
324 assert_eq!(
325 sync_record.initial_status,
326 Some(EffectActivationStatus::Pending)
327 );
328 assert!(sync_record.initial_plan_membership.is_some());
329 assert_eq!(sync_record.action_batches.len(), 2);
330 assert!(ready_record.activation_slot.is_some());
331 assert!(ready_record.action_batches.is_empty());
332 assert_ne!(sync_record.activation_slot, ready_record.activation_slot);
333 assert!(plan.record(&invalid).is_none());
334 assert!(validate_effect_resume_plan(&model, ®istry, &plan).is_empty());
335
336 let repeated = build_effect_resume_plan(&model, ®istry);
337 assert_eq!(plan, repeated);
338
339 let mut malformed = plan.clone();
340 let sync_index = malformed
341 .records
342 .iter()
343 .position(|record| record.effect == sync)
344 .expect("sync record index");
345 let ready_index = malformed
346 .records
347 .iter()
348 .position(|record| record.effect == ready)
349 .expect("ready record index");
350 malformed.records[sync_index].runtime_function = ready.clone();
351 malformed.records[sync_index]
352 .action_batches
353 .push(model.components[0].id.action_batch("missing"));
354 malformed.records[ready_index].activation_slot =
355 malformed.records[sync_index].activation_slot.clone();
356 let codes = validate_effect_resume_plan(&model, ®istry, &malformed)
357 .into_iter()
358 .map(|diagnostic| diagnostic.code)
359 .collect::<Vec<_>>();
360 assert!(codes.contains(&"PSRSM1105".to_string()));
361 assert!(codes.contains(&"PSRSM1110".to_string()));
362 assert!(codes.contains(&"PSRSM1111".to_string()));
363 }
364}