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 = 1;
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 execution_boundary: String,
31    pub resume_policy: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum RuntimePackageInvocationArtifactValidationError {
36    UnsupportedSchemaVersion { actual: u32 },
37    InvalidRegistryPath,
38    DuplicateInvocation { id: String },
39    MissingCoordinate { id: String },
40    InvalidLifecycle { id: String },
41}
42
43#[must_use]
44pub fn build_runtime_package_invocation_artifact(
45    model: &ApplicationSemanticModel,
46) -> RuntimePackageInvocationArtifact {
47    let mut invocations = model
48        .terminal_package_invocations
49        .iter()
50        .map(|fact| {
51            let mut declaration_modules = fact.declaration_modules.clone();
52            declaration_modules.sort();
53            declaration_modules.dedup();
54            RuntimePackageInvocation {
55                id: fact.id.to_string(),
56                owner_component: fact.owner_component.to_string(),
57                method: fact.method.to_string(),
58                module_specifier: fact.module_specifier.clone(),
59                export: fact.export_name.clone(),
60                owner_source: fact.provenance.path.to_string_lossy().into_owned(),
61                declaration_modules,
62                execution_boundary: "client".into(),
63                resume_policy: "event_restore".into(),
64            }
65        })
66        .collect::<Vec<_>>();
67    invocations.sort_by(|left, right| left.id.cmp(&right.id));
68    RuntimePackageInvocationArtifact {
69        schema_version: RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION,
70        registry_path: PACKAGE_INVOCATION_REGISTRY_PATH.into(),
71        invocations,
72    }
73}
74
75#[must_use]
76pub fn runtime_package_invocation_artifact_json(
77    artifact: &RuntimePackageInvocationArtifact,
78) -> String {
79    serde_json::to_string_pretty(artifact).expect("package invocation artifact should serialize")
80        + "\n"
81}
82
83#[must_use]
84pub fn validate_runtime_package_invocation_artifact(
85    artifact: &RuntimePackageInvocationArtifact,
86) -> Vec<RuntimePackageInvocationArtifactValidationError> {
87    let mut errors = Vec::new();
88    if artifact.schema_version != RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION {
89        errors.push(
90            RuntimePackageInvocationArtifactValidationError::UnsupportedSchemaVersion {
91                actual: artifact.schema_version,
92            },
93        );
94    }
95    if artifact.registry_path != PACKAGE_INVOCATION_REGISTRY_PATH {
96        errors.push(RuntimePackageInvocationArtifactValidationError::InvalidRegistryPath);
97    }
98    let mut ids = BTreeSet::new();
99    for invocation in &artifact.invocations {
100        if !ids.insert(invocation.id.clone()) {
101            errors.push(
102                RuntimePackageInvocationArtifactValidationError::DuplicateInvocation {
103                    id: invocation.id.clone(),
104                },
105            );
106        }
107        if invocation.id.is_empty()
108            || invocation.owner_component.is_empty()
109            || invocation.method.is_empty()
110            || invocation.module_specifier.is_empty()
111            || invocation.export.is_empty()
112            || invocation.owner_source.is_empty()
113            || invocation.declaration_modules.is_empty()
114        {
115            errors.push(
116                RuntimePackageInvocationArtifactValidationError::MissingCoordinate {
117                    id: invocation.id.clone(),
118                },
119            );
120        }
121        if invocation.execution_boundary != "client" || invocation.resume_policy != "event_restore"
122        {
123            errors.push(
124                RuntimePackageInvocationArtifactValidationError::InvalidLifecycle {
125                    id: invocation.id.clone(),
126                },
127            );
128        }
129    }
130    errors
131}
132
133#[cfg(test)]
134mod tests {
135    use super::{
136        runtime_package_invocation_artifact_json, validate_runtime_package_invocation_artifact,
137        RuntimePackageInvocation, RuntimePackageInvocationArtifact,
138        RuntimePackageInvocationArtifactValidationError, PACKAGE_INVOCATION_REGISTRY_PATH,
139        RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION,
140    };
141
142    fn artifact() -> RuntimePackageInvocationArtifact {
143        RuntimePackageInvocationArtifact {
144            schema_version: RUNTIME_PACKAGE_INVOCATION_ARTIFACT_SCHEMA_VERSION,
145            registry_path: PACKAGE_INVOCATION_REGISTRY_PATH.into(),
146            invocations: vec![RuntimePackageInvocation {
147                id: "component:Home/package-invocation:record".into(),
148                owner_component: "component:Home".into(),
149                method: "component:Home/action-endpoint:record".into(),
150                module_specifier: "analytics-kit".into(),
151                export: "recordVisit".into(),
152                owner_source: "app/routes/index.tsx".into(),
153                declaration_modules: vec!["node_modules/analytics-kit/index.d.ts".into()],
154                execution_boundary: "client".into(),
155                resume_policy: "event_restore".into(),
156            }],
157        }
158    }
159
160    #[test]
161    fn validates_and_serializes_exact_package_invocation_coordinates() {
162        let artifact = artifact();
163        assert!(validate_runtime_package_invocation_artifact(&artifact).is_empty());
164        let json = runtime_package_invocation_artifact_json(&artifact);
165        assert!(json.contains("\"registry_path\": \"/presolve.package-invocations.js\""));
166        assert!(json.contains("\"module_specifier\": \"analytics-kit\""));
167    }
168
169    #[test]
170    fn rejects_duplicate_missing_and_noncanonical_package_invocations() {
171        let mut artifact = artifact();
172        artifact.schema_version += 1;
173        artifact.registry_path = "relative.js".into();
174        artifact.invocations[0].module_specifier.clear();
175        artifact.invocations[0].resume_policy = "snapshot".into();
176        artifact.invocations.push(artifact.invocations[0].clone());
177        let errors = validate_runtime_package_invocation_artifact(&artifact);
178        assert!(errors.iter().any(|error| matches!(
179            error,
180            RuntimePackageInvocationArtifactValidationError::UnsupportedSchemaVersion { .. }
181        )));
182        assert!(errors.iter().any(|error| matches!(
183            error,
184            RuntimePackageInvocationArtifactValidationError::InvalidRegistryPath
185        )));
186        assert!(errors.iter().any(|error| matches!(
187            error,
188            RuntimePackageInvocationArtifactValidationError::DuplicateInvocation { .. }
189        )));
190        assert!(errors.iter().any(|error| matches!(
191            error,
192            RuntimePackageInvocationArtifactValidationError::MissingCoordinate { .. }
193        )));
194        assert!(errors.iter().any(|error| matches!(
195            error,
196            RuntimePackageInvocationArtifactValidationError::InvalidLifecycle { .. }
197        )));
198    }
199}