Skip to main content

presolve_compiler/
runtime_opaque_artifact.rs

1//! Deterministic runtime artifact for resolved opaque terminal Actions.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    ApplicationSemanticModel, OpaqueActionResolutionOutcome, SemanticPackageRuntimeModuleKey,
9    SemanticPackageRuntimeModuleTable,
10};
11
12pub const RUNTIME_OPAQUE_ARTIFACT_SCHEMA_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct RuntimeOpaqueArtifact {
17    pub schema_version: u32,
18    pub activations: Vec<RuntimeOpaqueArtifactActivation>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct RuntimeOpaqueArtifactActivation {
24    pub id: String,
25    pub owner_component: String,
26    pub method: String,
27    pub package: String,
28    pub version: String,
29    pub integrity: String,
30    pub export: String,
31    pub type_signature: String,
32    pub runtime_module: String,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub runtime_location: Option<String>,
35    pub execution_boundary: String,
36    pub resume_policy: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum RuntimeOpaqueArtifactBuildError {
41    MissingRuntimeModuleLocation { activation: String },
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum RuntimeOpaqueArtifactValidationError {
46    UnsupportedSchemaVersion { actual: u32 },
47    DuplicateActivationId { id: String },
48    MissingTerminalCoordinate { activation: String },
49    InvalidTerminalContract { activation: String },
50}
51
52/// Projects only resolved, compiler-owned opaque terminal selections. Invalid
53/// or unresolved source declarations cannot reach this artifact.
54#[must_use]
55pub fn build_runtime_opaque_artifact(model: &ApplicationSemanticModel) -> RuntimeOpaqueArtifact {
56    let activations = model
57        .opaque_action_resolutions
58        .iter()
59        .filter_map(|resolution| {
60            let OpaqueActionResolutionOutcome::Resolved(binding) = &resolution.outcome else {
61                return None;
62            };
63            Some(RuntimeOpaqueArtifactActivation {
64                id: resolution.activation.as_str().to_string(),
65                owner_component: resolution.owner_component.as_str().to_string(),
66                method: resolution.method.as_str().to_string(),
67                package: binding.package.clone(),
68                version: binding.version.clone(),
69                integrity: binding.integrity.clone(),
70                export: binding.export.clone(),
71                type_signature: binding.type_signature.clone(),
72                runtime_module: binding.runtime_module.clone(),
73                runtime_location: None,
74                execution_boundary: "client".to_string(),
75                resume_policy: binding.resume_policy.clone(),
76            })
77        })
78        .collect();
79    RuntimeOpaqueArtifact {
80        schema_version: RUNTIME_OPAQUE_ARTIFACT_SCHEMA_VERSION,
81        activations,
82    }
83}
84
85/// Binds runtime locations only through the exact package/version/integrity/
86/// module coordinate already selected by semantic resolution.
87pub fn build_runtime_opaque_artifact_with_modules(
88    model: &ApplicationSemanticModel,
89    modules: &SemanticPackageRuntimeModuleTable,
90) -> Result<RuntimeOpaqueArtifact, RuntimeOpaqueArtifactBuildError> {
91    let mut artifact = build_runtime_opaque_artifact(model);
92    for activation in &mut artifact.activations {
93        let key = SemanticPackageRuntimeModuleKey {
94            package: activation.package.clone(),
95            version: activation.version.clone(),
96            integrity: activation.integrity.clone(),
97            runtime_module: activation.runtime_module.clone(),
98        };
99        activation.runtime_location = Some(
100            modules
101                .resolve(&key)
102                .ok_or_else(
103                    || RuntimeOpaqueArtifactBuildError::MissingRuntimeModuleLocation {
104                        activation: activation.id.clone(),
105                    },
106                )?
107                .to_string(),
108        );
109    }
110    Ok(artifact)
111}
112
113#[must_use]
114pub fn runtime_opaque_artifact_json(artifact: &RuntimeOpaqueArtifact) -> String {
115    serde_json::to_string_pretty(artifact).expect("opaque artifact should serialize") + "\n"
116}
117
118#[must_use]
119pub fn validate_runtime_opaque_artifact(
120    artifact: &RuntimeOpaqueArtifact,
121) -> Vec<RuntimeOpaqueArtifactValidationError> {
122    let mut errors = Vec::new();
123    if artifact.schema_version != RUNTIME_OPAQUE_ARTIFACT_SCHEMA_VERSION {
124        errors.push(
125            RuntimeOpaqueArtifactValidationError::UnsupportedSchemaVersion {
126                actual: artifact.schema_version,
127            },
128        );
129    }
130    let mut ids = BTreeSet::new();
131    for activation in &artifact.activations {
132        if !ids.insert(activation.id.clone()) {
133            errors.push(
134                RuntimeOpaqueArtifactValidationError::DuplicateActivationId {
135                    id: activation.id.clone(),
136                },
137            );
138        }
139        if activation.id.is_empty()
140            || activation.owner_component.is_empty()
141            || activation.method.is_empty()
142            || activation.package.is_empty()
143            || activation.version.is_empty()
144            || activation.integrity.is_empty()
145            || activation.export.is_empty()
146            || activation.runtime_module.is_empty()
147        {
148            errors.push(
149                RuntimeOpaqueArtifactValidationError::MissingTerminalCoordinate {
150                    activation: activation.id.clone(),
151                },
152            );
153        }
154        if activation.type_signature != "() -> void"
155            || activation.execution_boundary != "client"
156            || activation.resume_policy != "cold_fallback"
157        {
158            errors.push(
159                RuntimeOpaqueArtifactValidationError::InvalidTerminalContract {
160                    activation: activation.id.clone(),
161                },
162            );
163        }
164    }
165    errors
166}
167
168#[cfg(test)]
169mod tests {
170    use crate::{
171        build_application_semantic_model_for_unit_with_packages, build_runtime_opaque_artifact,
172        build_runtime_opaque_artifact_with_modules, parse_semantic_package_contract,
173        validate_runtime_opaque_artifact, CompilationUnit, RuntimeOpaqueArtifactBuildError,
174        RuntimeOpaqueArtifactValidationError, SemanticPackageResolutionTable,
175        SemanticPackageRuntimeModuleKey, SemanticPackageRuntimeModuleTable,
176    };
177
178    fn model() -> crate::ApplicationSemanticModel {
179        let unit = CompilationUnit::parse_sources([(
180            "src/Checkout.tsx",
181            r#"
182import { trackPurchase } from "@acme/analytics";
183@component("x-checkout")
184class Checkout extends Component {
185  @action() @opaque("@acme/analytics", "trackPurchase") track(): void {}
186  render() { return <button onClick={this.track}>Buy</button>; }
187}
188"#,
189        )]);
190        let contract = parse_semantic_package_contract(
191            r#"{"schema_version":1,"package":"@acme/analytics","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"trackPurchase":{"kind":"opaque","type_signature":"() -> void","runtime_module":"dist/track.js","resume_policy":"cold_fallback","opaque_terminal":{"execution_boundary":"client","resume":"cold_fallback"}}}}"#,
192        )
193        .unwrap();
194        let mut packages = SemanticPackageResolutionTable::default();
195        packages.insert("@acme/analytics".into(), contract).unwrap();
196        build_application_semantic_model_for_unit_with_packages(&unit, &packages)
197    }
198
199    #[test]
200    fn projects_and_validates_resolved_opaque_terminal_artifact() {
201        let artifact = build_runtime_opaque_artifact(&model());
202        assert_eq!(artifact.activations.len(), 1);
203        assert_eq!(artifact.activations[0].export, "trackPurchase");
204        assert_eq!(artifact.activations[0].execution_boundary, "client");
205        assert!(validate_runtime_opaque_artifact(&artifact).is_empty());
206    }
207
208    #[test]
209    fn requires_an_exact_host_bound_runtime_module_location() {
210        let model = model();
211        let modules = SemanticPackageRuntimeModuleTable::default();
212        assert!(matches!(
213            build_runtime_opaque_artifact_with_modules(&model, &modules),
214            Err(RuntimeOpaqueArtifactBuildError::MissingRuntimeModuleLocation { .. })
215        ));
216
217        let mut modules = SemanticPackageRuntimeModuleTable::default();
218        modules
219            .insert(
220                SemanticPackageRuntimeModuleKey {
221                    package: "@acme/analytics".into(),
222                    version: "1.2.3".into(),
223                    integrity:
224                        "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
225                            .into(),
226                    runtime_module: "dist/track.js".into(),
227                },
228                "./vendor/track.js".into(),
229            )
230            .unwrap();
231        let artifact = build_runtime_opaque_artifact_with_modules(&model, &modules).unwrap();
232        assert_eq!(
233            artifact.activations[0].runtime_location.as_deref(),
234            Some("./vendor/track.js")
235        );
236    }
237
238    #[test]
239    fn rejects_malformed_opaque_terminal_artifacts() {
240        let mut artifact = build_runtime_opaque_artifact(&model());
241        artifact.schema_version = 2;
242        artifact.activations[0].type_signature = "(value: string) -> void".into();
243        artifact.activations[0].integrity.clear();
244        let errors = validate_runtime_opaque_artifact(&artifact);
245        assert!(errors.iter().any(|error| matches!(
246            error,
247            RuntimeOpaqueArtifactValidationError::UnsupportedSchemaVersion { actual: 2 }
248        )));
249        assert!(errors.iter().any(|error| matches!(
250            error,
251            RuntimeOpaqueArtifactValidationError::InvalidTerminalContract { .. }
252        )));
253        assert!(errors.iter().any(|error| matches!(
254            error,
255            RuntimeOpaqueArtifactValidationError::MissingTerminalCoordinate { .. }
256        )));
257    }
258}