1use std::collections::BTreeSet;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{
12 semantic_type_text, ApplicationSemanticModel, ResourceEndpointResolutionOutcome,
13 ResourceLifecycleState, SemanticPackageRuntimeModuleKey, SemanticPackageRuntimeModuleTable,
14};
15
16pub const RUNTIME_RESOURCE_ARTIFACT_SCHEMA_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct RuntimeResourceArtifact {
21 pub schema_version: u32,
22 pub declarations: Vec<RuntimeResourceArtifactDeclaration>,
23 pub activations: Vec<RuntimeResourceArtifactActivation>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct RuntimeResourceArtifactDeclaration {
29 pub id: String,
30 pub owner_component: String,
31 pub key: String,
32 pub data_type: String,
33 pub error_type: String,
34 pub execution_boundary: String,
35 pub input_dependencies: Vec<String>,
36 pub retry_policy: String,
37 pub invalidation_policy: String,
38 pub endpoint: RuntimeResourceArtifactEndpoint,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct RuntimeResourceArtifactEndpoint {
44 pub package: String,
45 pub version: String,
46 pub integrity: String,
47 pub export: String,
48 pub type_signature: String,
49 pub runtime_module: String,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub runtime_location: Option<String>,
52 pub resume_policy: String,
53 pub cancellation: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct RuntimeResourceArtifactActivation {
59 pub id: String,
60 pub declaration: String,
61 pub component_instance: String,
62 pub state: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub generation: Option<u64>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum RuntimeResourceArtifactValidationError {
69 UnsupportedSchemaVersion {
70 actual: u32,
71 },
72 DuplicateDeclarationId {
73 id: String,
74 },
75 DuplicateActivationId {
76 id: String,
77 },
78 MissingEndpointCoordinate {
79 declaration: String,
80 },
81 InvalidLifecycleGeneration {
82 activation: String,
83 },
84 UnknownActivationDeclaration {
85 activation: String,
86 declaration: String,
87 },
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum RuntimeResourceArtifactBuildError {
92 MissingRuntimeModuleLocation { declaration: String },
93}
94
95#[must_use]
102pub fn build_runtime_resource_artifact(
103 model: &ApplicationSemanticModel,
104) -> RuntimeResourceArtifact {
105 let declarations = model
106 .resource_declarations
107 .values()
108 .map(|declaration| {
109 let endpoint = model
110 .resource_endpoint_resolutions
111 .iter()
112 .find(|resolution| {
113 resolution.owner_component == declaration.owner_component
114 && resolution.field == declaration.name
115 })
116 .and_then(|resolution| match &resolution.outcome {
117 ResourceEndpointResolutionOutcome::Resolved(endpoint) => Some(endpoint),
118 _ => None,
119 })
120 .expect("projected Resource declaration must have resolved endpoint");
121 RuntimeResourceArtifactDeclaration {
122 id: declaration.id.as_str().to_string(),
123 owner_component: declaration.owner_component.as_str().to_string(),
124 key: declaration.key.clone(),
125 data_type: semantic_type_text(&declaration.data_type),
126 error_type: semantic_type_text(&declaration.error_type),
127 execution_boundary: format!("{:?}", declaration.execution_boundary),
128 input_dependencies: declaration
129 .input_dependencies
130 .iter()
131 .map(|dependency| dependency.as_str().to_string())
132 .collect(),
133 retry_policy: format!("{:?}", declaration.retry_policy),
134 invalidation_policy: format!("{:?}", declaration.invalidation_policy),
135 endpoint: RuntimeResourceArtifactEndpoint {
136 package: endpoint.package.clone(),
137 version: endpoint.version.clone(),
138 integrity: endpoint.integrity.clone(),
139 export: endpoint.export.clone(),
140 type_signature: endpoint.type_signature.clone(),
141 runtime_module: endpoint.runtime_module.clone(),
142 runtime_location: None,
143 resume_policy: endpoint.resume_policy.clone(),
144 cancellation: format!("{:?}", endpoint.endpoint.cancellation),
145 },
146 }
147 })
148 .collect();
149 let activations = model
150 .resource_activations
151 .values()
152 .map(|activation| {
153 let (state, generation) = resource_lifecycle_artifact_state(activation.state);
154 RuntimeResourceArtifactActivation {
155 id: activation.id.as_str().to_string(),
156 declaration: activation.declaration.as_str().to_string(),
157 component_instance: activation.component_instance.as_str().to_string(),
158 state: state.to_string(),
159 generation,
160 }
161 })
162 .collect();
163 RuntimeResourceArtifact {
164 schema_version: RUNTIME_RESOURCE_ARTIFACT_SCHEMA_VERSION,
165 declarations,
166 activations,
167 }
168}
169
170pub fn build_runtime_resource_artifact_with_modules(
173 model: &ApplicationSemanticModel,
174 modules: &SemanticPackageRuntimeModuleTable,
175) -> Result<RuntimeResourceArtifact, RuntimeResourceArtifactBuildError> {
176 let mut artifact = build_runtime_resource_artifact(model);
177 for declaration in &mut artifact.declarations {
178 let key = SemanticPackageRuntimeModuleKey {
179 package: declaration.endpoint.package.clone(),
180 version: declaration.endpoint.version.clone(),
181 integrity: declaration.endpoint.integrity.clone(),
182 runtime_module: declaration.endpoint.runtime_module.clone(),
183 };
184 declaration.endpoint.runtime_location = Some(
185 modules
186 .resolve(&key)
187 .ok_or_else(
188 || RuntimeResourceArtifactBuildError::MissingRuntimeModuleLocation {
189 declaration: declaration.id.clone(),
190 },
191 )?
192 .to_string(),
193 );
194 }
195 Ok(artifact)
196}
197
198#[must_use]
199pub fn runtime_resource_artifact_json(artifact: &RuntimeResourceArtifact) -> String {
200 serde_json::to_string_pretty(artifact).expect("resource artifact should serialize") + "\n"
201}
202
203#[must_use]
206pub fn validate_runtime_resource_artifact(
207 artifact: &RuntimeResourceArtifact,
208) -> Vec<RuntimeResourceArtifactValidationError> {
209 let mut errors = Vec::new();
210 if artifact.schema_version != RUNTIME_RESOURCE_ARTIFACT_SCHEMA_VERSION {
211 errors.push(
212 RuntimeResourceArtifactValidationError::UnsupportedSchemaVersion {
213 actual: artifact.schema_version,
214 },
215 );
216 }
217 let mut declarations = BTreeSet::new();
218 for declaration in &artifact.declarations {
219 if !declarations.insert(declaration.id.clone()) {
220 errors.push(
221 RuntimeResourceArtifactValidationError::DuplicateDeclarationId {
222 id: declaration.id.clone(),
223 },
224 );
225 }
226 if declaration.endpoint.package.is_empty()
227 || declaration.endpoint.version.is_empty()
228 || declaration.endpoint.integrity.is_empty()
229 || declaration.endpoint.export.is_empty()
230 || declaration.endpoint.runtime_module.is_empty()
231 {
232 errors.push(
233 RuntimeResourceArtifactValidationError::MissingEndpointCoordinate {
234 declaration: declaration.id.clone(),
235 },
236 );
237 }
238 }
239 let mut activations = BTreeSet::new();
240 for activation in &artifact.activations {
241 if !activations.insert(activation.id.clone()) {
242 errors.push(
243 RuntimeResourceArtifactValidationError::DuplicateActivationId {
244 id: activation.id.clone(),
245 },
246 );
247 }
248 let generation_required = matches!(
249 activation.state.as_str(),
250 "pending" | "ready" | "failed" | "cancelled"
251 );
252 if generation_required != activation.generation.is_some() {
253 errors.push(
254 RuntimeResourceArtifactValidationError::InvalidLifecycleGeneration {
255 activation: activation.id.clone(),
256 },
257 );
258 }
259 if !declarations.contains(&activation.declaration) {
260 errors.push(
261 RuntimeResourceArtifactValidationError::UnknownActivationDeclaration {
262 activation: activation.id.clone(),
263 declaration: activation.declaration.clone(),
264 },
265 );
266 }
267 }
268 errors
269}
270
271fn resource_lifecycle_artifact_state(state: ResourceLifecycleState) -> (&'static str, Option<u64>) {
272 match state {
273 ResourceLifecycleState::Idle => ("idle", None),
274 ResourceLifecycleState::Pending { generation } => ("pending", Some(generation)),
275 ResourceLifecycleState::Ready { generation } => ("ready", Some(generation)),
276 ResourceLifecycleState::Failed { generation } => ("failed", Some(generation)),
277 ResourceLifecycleState::Cancelled { generation } => ("cancelled", Some(generation)),
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use crate::{
284 build_application_semantic_model_for_unit_with_packages, build_runtime_resource_artifact,
285 build_runtime_resource_artifact_with_modules, parse_semantic_package_contract,
286 runtime_resource_artifact_json, validate_runtime_resource_artifact, CompilationUnit,
287 SemanticPackageResolutionTable, SemanticPackageRuntimeModuleKey,
288 SemanticPackageRuntimeModuleTable, RUNTIME_RESOURCE_ARTIFACT_SCHEMA_VERSION,
289 };
290
291 fn model() -> crate::ApplicationSemanticModel {
292 let unit = CompilationUnit::parse_sources([(
293 "src/Profile.tsx",
294 r#"
295import { loadProfile } from "profile-service";
296@component("x-profile")
297class Profile extends Component {
298 @resource("loadProfile") profile!: Resource<string, string>;
299 render() { return <div>Profile</div>; }
300}
301"#,
302 )]);
303 let contract = parse_semantic_package_contract(
304 r#"{"schema_version":1,"package":"profile-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"loadProfile":{"kind":"resource","type_signature":"(ProfileKey) -> Resource<Profile, ProfileError>","runtime_module":"dist/load-profile.js","resume_policy":"snapshot","resource_endpoint":{"execution_boundary":"shared","cancellation":"abort","resume":"snapshot"}}}}"#,
305 )
306 .expect("resource contract");
307 let mut packages = SemanticPackageResolutionTable::default();
308 packages.insert("profile-service".into(), contract).unwrap();
309 build_application_semantic_model_for_unit_with_packages(&unit, &packages)
310 }
311
312 #[test]
313 fn projects_resolved_resource_declaration_and_idle_activation_deterministically() {
314 let model = model();
315 let artifact = build_runtime_resource_artifact(&model);
316 assert_eq!(
317 artifact.schema_version,
318 RUNTIME_RESOURCE_ARTIFACT_SCHEMA_VERSION
319 );
320 assert_eq!(artifact.declarations.len(), 1);
321 assert_eq!(artifact.declarations[0].endpoint.package, "profile-service");
322 assert_eq!(artifact.declarations[0].endpoint.export, "loadProfile");
323 assert_eq!(artifact.activations.len(), 1);
324 assert_eq!(artifact.activations[0].state, "idle");
325 assert!(validate_runtime_resource_artifact(&artifact).is_empty());
326 assert_eq!(
327 runtime_resource_artifact_json(&artifact),
328 runtime_resource_artifact_json(&build_runtime_resource_artifact(&model))
329 );
330 }
331
332 #[test]
333 fn rejects_malformed_resource_artifact_identity_and_lifecycle_records() {
334 let model = model();
335 let mut artifact = build_runtime_resource_artifact(&model);
336 artifact.schema_version = 9;
337 artifact.declarations[0].endpoint.integrity.clear();
338 artifact.activations[0].declaration = "resource:missing".to_string();
339 artifact.activations[0].state = "ready".to_string();
340
341 let errors = validate_runtime_resource_artifact(&artifact);
342 assert!(errors.iter().any(|error| matches!(
343 error,
344 crate::RuntimeResourceArtifactValidationError::UnsupportedSchemaVersion { actual: 9 }
345 )));
346 assert!(errors.iter().any(|error| matches!(
347 error,
348 crate::RuntimeResourceArtifactValidationError::MissingEndpointCoordinate { .. }
349 )));
350 assert!(errors.iter().any(|error| matches!(
351 error,
352 crate::RuntimeResourceArtifactValidationError::InvalidLifecycleGeneration { .. }
353 )));
354 assert!(errors.iter().any(|error| matches!(
355 error,
356 crate::RuntimeResourceArtifactValidationError::UnknownActivationDeclaration { .. }
357 )));
358 }
359
360 #[test]
361 fn requires_exact_runtime_module_location_for_execution_facing_artifact() {
362 let model = model();
363 let mut modules = SemanticPackageRuntimeModuleTable::default();
364 assert!(matches!(
365 build_runtime_resource_artifact_with_modules(&model, &modules),
366 Err(crate::RuntimeResourceArtifactBuildError::MissingRuntimeModuleLocation { .. })
367 ));
368 modules
369 .insert(
370 SemanticPackageRuntimeModuleKey {
371 package: "profile-service".into(),
372 version: "1.2.3".into(),
373 integrity:
374 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
375 .into(),
376 runtime_module: "dist/load-profile.js".into(),
377 },
378 "./vendor/profile-service.js".into(),
379 )
380 .unwrap();
381 let artifact = build_runtime_resource_artifact_with_modules(&model, &modules).unwrap();
382 assert_eq!(
383 artifact.declarations[0]
384 .endpoint
385 .runtime_location
386 .as_deref(),
387 Some("./vendor/profile-service.js")
388 );
389 }
390}