Skip to main content

presolve_compiler/
runtime_package_invocation_artifact.rs

1//! Deterministic runtime metadata for decorator-free terminal package calls.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::ApplicationSemanticModel;
8
9pub const RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION: u32 = 2;
10pub const PACKAGE_INVOCATION_REGISTRY_PATH: &str = "/presolve.package-invocations.js";
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct RuntimePackageInvocationArtifact {
15    pub schema_version: u32,
16    pub registry_path: String,
17    pub invocations: Vec<RuntimePackageInvocation>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct RuntimePackageInvocation {
23    pub id: String,
24    pub owner_component: String,
25    pub method: String,
26    pub module_specifier: String,
27    pub export: String,
28    pub owner_source: String,
29    pub declaration_modules: Vec<String>,
30    pub arguments: Vec<RuntimePackageInvocationArgument>,
31    pub completion: String,
32    pub inject_abort_signal: bool,
33    pub concurrency: String,
34    pub cancellation: String,
35    pub execution_boundary: String,
36    pub resume_policy: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct RuntimePackageInvocationArgument {
42    pub event_argument_index: usize,
43    pub codec: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum RuntimePackageInvocationArtifactValidationError {
48    UnsupportedSchemaVersion { actual: u32 },
49    InvalidRegistryPath,
50    DuplicateInvocation { id: String },
51    MissingCoordinate { id: String },
52    InvalidLifecycle { id: String },
53}
54
55#[must_use]
56pub fn build_runtime_package_invocation_artifact(
57    model: &ApplicationSemanticModel,
58) -> RuntimePackageInvocationArtifact {
59    let mut invocations = model
60        .terminal_package_invocations
61        .iter()
62        .map(|fact| {
63            let mut declaration_modules = fact.declaration_modules.clone();
64            declaration_modules.sort();
65            declaration_modules.dedup();
66            RuntimePackageInvocation {
67                id: fact.id.to_string(),
68                owner_component: fact.owner_component.to_string(),
69                method: fact.method.to_string(),
70                module_specifier: fact.module_specifier.clone(),
71                export: fact.export_name.clone(),
72                owner_source: fact.provenance.path.to_string_lossy().into_owned(),
73                declaration_modules,
74                arguments: fact
75                    .argument_types
76                    .iter()
77                    .enumerate()
78                    .map(
79                        |(event_argument_index, codec)| RuntimePackageInvocationArgument {
80                            event_argument_index,
81                            codec: codec.clone(),
82                        },
83                    )
84                    .collect(),
85                completion: match fact.completion {
86                    crate::PackageInvocationCompletionV1::Synchronous => "synchronous".into(),
87                    crate::PackageInvocationCompletionV1::Promise => "promise".into(),
88                },
89                inject_abort_signal: fact.inject_abort_signal,
90                concurrency: if fact.completion == crate::PackageInvocationCompletionV1::Promise {
91                    "replace_previous_per_component_instance".into()
92                } else {
93                    "none".into()
94                },
95                cancellation: if fact.completion == crate::PackageInvocationCompletionV1::Promise {
96                    "abort_on_replacement_teardown_or_pagehide".into()
97                } else {
98                    "none".into()
99                },
100                execution_boundary: "client".into(),
101                resume_policy: "restore_event_without_replay".into(),
102            }
103        })
104        .collect::<Vec<_>>();
105    invocations.sort_by(|left, right| left.id.cmp(&right.id));
106    RuntimePackageInvocationArtifact {
107        schema_version: RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION,
108        registry_path: PACKAGE_INVOCATION_REGISTRY_PATH.into(),
109        invocations,
110    }
111}
112
113#[must_use]
114pub fn runtime_package_invocation_artifact_json(
115    artifact: &RuntimePackageInvocationArtifact,
116) -> String {
117    serde_json::to_string_pretty(artifact).expect("package invocation artifact should serialize")
118        + "\n"
119}
120
121#[must_use]
122pub fn validate_runtime_package_invocation_artifact(
123    artifact: &RuntimePackageInvocationArtifact,
124) -> Vec<RuntimePackageInvocationArtifactValidationError> {
125    let mut errors = Vec::new();
126    if artifact.schema_version != RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION {
127        errors.push(
128            RuntimePackageInvocationArtifactValidationError::UnsupportedSchemaVersion {
129                actual: artifact.schema_version,
130            },
131        );
132    }
133    if artifact.registry_path != PACKAGE_INVOCATION_REGISTRY_PATH {
134        errors.push(RuntimePackageInvocationArtifactValidationError::InvalidRegistryPath);
135    }
136    let mut ids = BTreeSet::new();
137    for invocation in &artifact.invocations {
138        if !ids.insert(invocation.id.clone()) {
139            errors.push(
140                RuntimePackageInvocationArtifactValidationError::DuplicateInvocation {
141                    id: invocation.id.clone(),
142                },
143            );
144        }
145        if invocation.id.is_empty()
146            || invocation.owner_component.is_empty()
147            || invocation.method.is_empty()
148            || invocation.module_specifier.is_empty()
149            || invocation.export.is_empty()
150            || invocation.owner_source.is_empty()
151            || invocation.declaration_modules.is_empty()
152        {
153            errors.push(
154                RuntimePackageInvocationArtifactValidationError::MissingCoordinate {
155                    id: invocation.id.clone(),
156                },
157            );
158        }
159        let valid_arguments = invocation
160            .arguments
161            .iter()
162            .enumerate()
163            .all(|(index, argument)| {
164                argument.event_argument_index == index
165                    && matches!(
166                        argument.codec.as_str(),
167                        "string" | "number" | "boolean" | "null"
168                    )
169            });
170        let valid_completion = match invocation.completion.as_str() {
171            "synchronous" => {
172                !invocation.inject_abort_signal
173                    && invocation.concurrency == "none"
174                    && invocation.cancellation == "none"
175            }
176            "promise" => {
177                invocation.inject_abort_signal
178                    && invocation.concurrency == "replace_previous_per_component_instance"
179                    && invocation.cancellation == "abort_on_replacement_teardown_or_pagehide"
180            }
181            _ => false,
182        };
183        if invocation.execution_boundary != "client"
184            || invocation.resume_policy != "restore_event_without_replay"
185            || !valid_arguments
186            || !valid_completion
187        {
188            errors.push(
189                RuntimePackageInvocationArtifactValidationError::InvalidLifecycle {
190                    id: invocation.id.clone(),
191                },
192            );
193        }
194    }
195    errors
196}
197
198#[cfg(test)]
199mod tests {
200    use super::{
201        runtime_package_invocation_artifact_json, validate_runtime_package_invocation_artifact,
202        RuntimePackageInvocation, RuntimePackageInvocationArgument,
203        RuntimePackageInvocationArtifact, RuntimePackageInvocationArtifactValidationError,
204        PACKAGE_INVOCATION_REGISTRY_PATH, RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION,
205    };
206
207    fn artifact() -> RuntimePackageInvocationArtifact {
208        RuntimePackageInvocationArtifact {
209            schema_version: RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION,
210            registry_path: PACKAGE_INVOCATION_REGISTRY_PATH.into(),
211            invocations: vec![RuntimePackageInvocation {
212                id: "component:Home/package-invocation:record".into(),
213                owner_component: "component:Home".into(),
214                method: "component:Home/action-endpoint:record".into(),
215                module_specifier: "analytics-kit".into(),
216                export: "recordVisit".into(),
217                owner_source: "app/routes/index.tsx".into(),
218                declaration_modules: vec!["node_modules/analytics-kit/index.d.ts".into()],
219                arguments: vec![RuntimePackageInvocationArgument {
220                    event_argument_index: 0,
221                    codec: "string".into(),
222                }],
223                completion: "synchronous".into(),
224                inject_abort_signal: false,
225                concurrency: "none".into(),
226                cancellation: "none".into(),
227                execution_boundary: "client".into(),
228                resume_policy: "restore_event_without_replay".into(),
229            }],
230        }
231    }
232
233    #[test]
234    fn validates_and_serializes_exact_package_invocation_coordinates() {
235        let artifact = artifact();
236        assert!(validate_runtime_package_invocation_artifact(&artifact).is_empty());
237        let json = runtime_package_invocation_artifact_json(&artifact);
238        assert!(json.contains("\"registry_path\": \"/presolve.package-invocations.js\""));
239        assert!(json.contains("\"module_specifier\": \"analytics-kit\""));
240    }
241
242    #[test]
243    fn rejects_duplicate_missing_and_noncanonical_package_invocations() {
244        let mut artifact = artifact();
245        artifact.schema_version += 1;
246        artifact.registry_path = "relative.js".into();
247        artifact.invocations[0].module_specifier.clear();
248        artifact.invocations[0].resume_policy = "snapshot".into();
249        artifact.invocations[0].arguments[0].codec = "object".into();
250        artifact.invocations.push(artifact.invocations[0].clone());
251        let errors = validate_runtime_package_invocation_artifact(&artifact);
252        assert!(errors.iter().any(|error| matches!(
253            error,
254            RuntimePackageInvocationArtifactValidationError::UnsupportedSchemaVersion { .. }
255        )));
256        assert!(errors.iter().any(|error| matches!(
257            error,
258            RuntimePackageInvocationArtifactValidationError::InvalidRegistryPath
259        )));
260        assert!(errors.iter().any(|error| matches!(
261            error,
262            RuntimePackageInvocationArtifactValidationError::DuplicateInvocation { .. }
263        )));
264        assert!(errors.iter().any(|error| matches!(
265            error,
266            RuntimePackageInvocationArtifactValidationError::MissingCoordinate { .. }
267        )));
268        assert!(errors.iter().any(|error| matches!(
269            error,
270            RuntimePackageInvocationArtifactValidationError::InvalidLifecycle { .. }
271        )));
272    }
273}