1use std::collections::BTreeSet;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{
12 resume_value_codec, semantic_type_text, ApplicationSemanticModel,
13 ResourceEndpointResolutionOutcome, ResourceLifecycleState, ResumeValueCodec,
14 SemanticPackageRuntimeModuleKey, SemanticPackageRuntimeModuleTable,
15};
16
17pub const RUNTIME_RESOURCE_ARTIFACT_SCHEMA_VERSION: u32 = 3;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct RuntimeResourceArtifact {
24 pub schema_version: u32,
25 pub declarations: Vec<RuntimeResourceArtifactDeclaration>,
26 pub activations: Vec<RuntimeResourceArtifactActivation>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct RuntimeResourceArtifactDeclaration {
32 pub id: String,
33 pub owner_component: String,
34 pub key: String,
35 pub data_type: String,
36 pub error_type: String,
37 pub data_codec: ResumeValueCodec,
38 pub error_codec: ResumeValueCodec,
39 pub execution_boundary: String,
40 pub input_dependencies: Vec<String>,
41 pub retry_policy: String,
42 pub invalidation_policy: String,
43 pub endpoint: RuntimeResourceArtifactEndpoint,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct RuntimeResourceArtifactEndpoint {
49 pub package: String,
50 pub version: String,
51 pub integrity: String,
52 pub export: String,
53 pub type_signature: String,
54 pub runtime_module: String,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub runtime_location: Option<String>,
57 pub resume_policy: String,
58 pub cancellation: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct RuntimeResourceArtifactActivation {
64 pub id: String,
65 pub declaration: String,
66 pub component_instance: String,
67 pub state_slot: String,
68 pub data_slot: String,
69 pub error_slot: String,
70 pub state: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub generation: Option<u64>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum RuntimeResourceArtifactValidationError {
77 UnsupportedSchemaVersion {
78 actual: u32,
79 },
80 DuplicateDeclarationId {
81 id: String,
82 },
83 DuplicateActivationId {
84 id: String,
85 },
86 MissingEndpointCoordinate {
87 declaration: String,
88 },
89 InvalidLifecycleGeneration {
90 activation: String,
91 },
92 UnknownActivationDeclaration {
93 activation: String,
94 declaration: String,
95 },
96 InvalidResumeSlotIdentity {
97 activation: String,
98 },
99 InvalidValueCodec {
100 declaration: String,
101 },
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum RuntimeResourceArtifactBuildError {
106 MissingRuntimeModuleLocation { declaration: String },
107}
108
109#[must_use]
116pub fn build_runtime_resource_artifact(
117 model: &ApplicationSemanticModel,
118) -> RuntimeResourceArtifact {
119 let declarations = model
120 .resource_declarations
121 .values()
122 .map(|declaration| {
123 let endpoint = model
124 .resource_endpoint_resolutions
125 .iter()
126 .find(|resolution| {
127 resolution.owner_component == declaration.owner_component
128 && resolution.field == declaration.name
129 })
130 .and_then(|resolution| match &resolution.outcome {
131 ResourceEndpointResolutionOutcome::Resolved(endpoint) => Some(endpoint),
132 _ => None,
133 })
134 .expect("projected Resource declaration must have resolved endpoint");
135 RuntimeResourceArtifactDeclaration {
136 id: declaration.id.as_str().to_string(),
137 owner_component: declaration.owner_component.as_str().to_string(),
138 key: declaration.key.clone(),
139 data_type: semantic_type_text(&declaration.data_type),
140 error_type: semantic_type_text(&declaration.error_type),
141 data_codec: resume_value_codec(&declaration.data_type).expect(
142 "Resource declarations must use the compiler's closed runtime value codec vocabulary",
143 ),
144 error_codec: resume_value_codec(&declaration.error_type).expect(
145 "Resource declarations must use the compiler's closed runtime value codec vocabulary",
146 ),
147 execution_boundary: format!("{:?}", declaration.execution_boundary),
148 input_dependencies: declaration
149 .input_dependencies
150 .iter()
151 .map(|dependency| dependency.as_str().to_string())
152 .collect(),
153 retry_policy: format!("{:?}", declaration.retry_policy),
154 invalidation_policy: format!("{:?}", declaration.invalidation_policy),
155 endpoint: RuntimeResourceArtifactEndpoint {
156 package: endpoint.package.clone(),
157 version: endpoint.version.clone(),
158 integrity: endpoint.integrity.clone(),
159 export: endpoint.export.clone(),
160 type_signature: endpoint.type_signature.clone(),
161 runtime_module: endpoint.runtime_module.clone(),
162 runtime_location: None,
163 resume_policy: endpoint.resume_policy.clone(),
164 cancellation: format!("{:?}", endpoint.endpoint.cancellation),
165 },
166 }
167 })
168 .collect();
169 let activations = model
170 .resource_activations
171 .values()
172 .map(|activation| {
173 let (state, generation) = resource_lifecycle_artifact_state(activation.state);
174 RuntimeResourceArtifactActivation {
175 id: activation.id.as_str().to_string(),
176 declaration: activation.declaration.as_str().to_string(),
177 component_instance: activation.component_instance.as_str().to_string(),
178 state_slot: activation.id.state_slot().as_str().to_string(),
179 data_slot: activation.id.data_slot().as_str().to_string(),
180 error_slot: activation.id.error_slot().as_str().to_string(),
181 state: state.to_string(),
182 generation,
183 }
184 })
185 .collect();
186 RuntimeResourceArtifact {
187 schema_version: RUNTIME_RESOURCE_ARTIFACT_SCHEMA_VERSION,
188 declarations,
189 activations,
190 }
191}
192
193pub fn build_runtime_resource_artifact_with_modules(
196 model: &ApplicationSemanticModel,
197 modules: &SemanticPackageRuntimeModuleTable,
198) -> Result<RuntimeResourceArtifact, RuntimeResourceArtifactBuildError> {
199 let mut artifact = build_runtime_resource_artifact(model);
200 for declaration in &mut artifact.declarations {
201 let key = SemanticPackageRuntimeModuleKey {
202 package: declaration.endpoint.package.clone(),
203 version: declaration.endpoint.version.clone(),
204 integrity: declaration.endpoint.integrity.clone(),
205 runtime_module: declaration.endpoint.runtime_module.clone(),
206 };
207 declaration.endpoint.runtime_location = Some(
208 modules
209 .resolve(&key)
210 .ok_or_else(
211 || RuntimeResourceArtifactBuildError::MissingRuntimeModuleLocation {
212 declaration: declaration.id.clone(),
213 },
214 )?
215 .to_string(),
216 );
217 }
218 Ok(artifact)
219}
220
221#[must_use]
222pub fn runtime_resource_artifact_json(artifact: &RuntimeResourceArtifact) -> String {
223 serde_json::to_string_pretty(artifact).expect("resource artifact should serialize") + "\n"
224}
225
226#[must_use]
229pub fn validate_runtime_resource_artifact(
230 artifact: &RuntimeResourceArtifact,
231) -> Vec<RuntimeResourceArtifactValidationError> {
232 let mut errors = Vec::new();
233 if artifact.schema_version != RUNTIME_RESOURCE_ARTIFACT_SCHEMA_VERSION {
234 errors.push(
235 RuntimeResourceArtifactValidationError::UnsupportedSchemaVersion {
236 actual: artifact.schema_version,
237 },
238 );
239 }
240 let mut declarations = BTreeSet::new();
241 for declaration in &artifact.declarations {
242 if !declarations.insert(declaration.id.clone()) {
243 errors.push(
244 RuntimeResourceArtifactValidationError::DuplicateDeclarationId {
245 id: declaration.id.clone(),
246 },
247 );
248 }
249 if declaration.endpoint.package.is_empty()
250 || declaration.endpoint.version.is_empty()
251 || declaration.endpoint.integrity.is_empty()
252 || declaration.endpoint.export.is_empty()
253 || declaration.endpoint.runtime_module.is_empty()
254 {
255 errors.push(
256 RuntimeResourceArtifactValidationError::MissingEndpointCoordinate {
257 declaration: declaration.id.clone(),
258 },
259 );
260 }
261 if !is_valid_value_codec(&declaration.data_codec)
262 || !is_valid_value_codec(&declaration.error_codec)
263 {
264 errors.push(RuntimeResourceArtifactValidationError::InvalidValueCodec {
265 declaration: declaration.id.clone(),
266 });
267 }
268 }
269 let mut activations = BTreeSet::new();
270 for activation in &artifact.activations {
271 if !activations.insert(activation.id.clone()) {
272 errors.push(
273 RuntimeResourceArtifactValidationError::DuplicateActivationId {
274 id: activation.id.clone(),
275 },
276 );
277 }
278 let generation_required = matches!(
279 activation.state.as_str(),
280 "pending" | "ready" | "failed" | "cancelled"
281 );
282 if generation_required != activation.generation.is_some() {
283 errors.push(
284 RuntimeResourceArtifactValidationError::InvalidLifecycleGeneration {
285 activation: activation.id.clone(),
286 },
287 );
288 }
289 if !declarations.contains(&activation.declaration) {
290 errors.push(
291 RuntimeResourceArtifactValidationError::UnknownActivationDeclaration {
292 activation: activation.id.clone(),
293 declaration: activation.declaration.clone(),
294 },
295 );
296 }
297 let expected_prefix = format!("{}/resource-slot:", activation.id);
298 if activation.state_slot != format!("{expected_prefix}state")
299 || activation.data_slot != format!("{expected_prefix}data")
300 || activation.error_slot != format!("{expected_prefix}error")
301 || activation.state_slot == activation.data_slot
302 || activation.state_slot == activation.error_slot
303 || activation.data_slot == activation.error_slot
304 {
305 errors.push(
306 RuntimeResourceArtifactValidationError::InvalidResumeSlotIdentity {
307 activation: activation.id.clone(),
308 },
309 );
310 }
311 }
312 errors
313}
314
315fn is_valid_value_codec(codec: &ResumeValueCodec) -> bool {
316 match codec {
317 ResumeValueCodec::NullCodec
318 | ResumeValueCodec::BooleanCodec
319 | ResumeValueCodec::NumberCodec
320 | ResumeValueCodec::StringCodec => true,
321 ResumeValueCodec::ArrayCodec(element) | ResumeValueCodec::NullableCodec(element) => {
322 is_valid_value_codec(element)
323 }
324 ResumeValueCodec::ObjectCodec(properties) => {
325 let mut names = BTreeSet::new();
326 properties.iter().all(|property| {
327 !property.name.is_empty()
328 && names.insert(property.name.as_str())
329 && is_valid_value_codec(&property.codec)
330 })
331 }
332 }
333}
334
335fn resource_lifecycle_artifact_state(state: ResourceLifecycleState) -> (&'static str, Option<u64>) {
336 match state {
337 ResourceLifecycleState::Idle => ("idle", None),
338 ResourceLifecycleState::Pending { generation } => ("pending", Some(generation)),
339 ResourceLifecycleState::Ready { generation } => ("ready", Some(generation)),
340 ResourceLifecycleState::Failed { generation } => ("failed", Some(generation)),
341 ResourceLifecycleState::Cancelled { generation } => ("cancelled", Some(generation)),
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use crate::{
348 build_application_semantic_model_for_unit_with_packages, build_runtime_resource_artifact,
349 build_runtime_resource_artifact_with_modules, parse_semantic_package_contract,
350 runtime_resource_artifact_json, validate_runtime_resource_artifact, CompilationUnit,
351 SemanticPackageResolutionTable, SemanticPackageRuntimeModuleKey,
352 SemanticPackageRuntimeModuleTable, RUNTIME_RESOURCE_ARTIFACT_SCHEMA_VERSION,
353 };
354
355 fn model() -> crate::ApplicationSemanticModel {
356 let unit = CompilationUnit::parse_sources([(
357 "src/Profile.tsx",
358 r#"
359import { loadProfile } from "profile-service";
360@component("x-profile")
361class Profile extends Component {
362 @resource("loadProfile") profile!: Resource<string, string>;
363 render() { return <div>Profile</div>; }
364}
365"#,
366 )]);
367 let contract = parse_semantic_package_contract(
368 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"}}}}"#,
369 )
370 .expect("resource contract");
371 let mut packages = SemanticPackageResolutionTable::default();
372 packages.insert("profile-service".into(), contract).unwrap();
373 build_application_semantic_model_for_unit_with_packages(&unit, &packages)
374 }
375
376 fn ordered_model(reverse: bool) -> crate::ApplicationSemanticModel {
377 let first = (
378 "src/Account.tsx",
379 r#"import { loadAccount } from "profile-service";
380@component("x-account") @route("/account") class Account extends Component {
381 @resource("loadAccount") account!: Resource<string, string>;
382 render() { return <main>Account</main>; }
383}"#,
384 );
385 let second = (
386 "src/Profile.tsx",
387 r#"import { loadProfile } from "profile-service";
388@component("x-profile") @route("/profile") class Profile extends Component {
389 @resource("loadProfile") profile!: Resource<string, string>;
390 render() { return <main>Profile</main>; }
391}"#,
392 );
393 let files = if reverse {
394 [second, first]
395 } else {
396 [first, second]
397 };
398 let unit = CompilationUnit::parse_sources(files);
399 let contract = parse_semantic_package_contract(
400 r#"{"schema_version":1,"package":"profile-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{"loadAccount":{"kind":"resource","type_signature":"() -> Resource<string, string>","runtime_module":"dist/load-account.js","resume_policy":"snapshot","resource_endpoint":{"execution_boundary":"shared","cancellation":"abort","resume":"snapshot"}},"loadProfile":{"kind":"resource","type_signature":"() -> Resource<string, string>","runtime_module":"dist/load-profile.js","resume_policy":"snapshot","resource_endpoint":{"execution_boundary":"shared","cancellation":"abort","resume":"snapshot"}}}}"#,
401 )
402 .expect("resource contract");
403 let mut packages = SemanticPackageResolutionTable::default();
404 packages.insert("profile-service".into(), contract).unwrap();
405 build_application_semantic_model_for_unit_with_packages(&unit, &packages)
406 }
407
408 #[test]
409 fn projects_resolved_resource_declaration_and_idle_activation_deterministically() {
410 let model = model();
411 let artifact = build_runtime_resource_artifact(&model);
412 assert_eq!(
413 artifact.schema_version,
414 RUNTIME_RESOURCE_ARTIFACT_SCHEMA_VERSION
415 );
416 assert_eq!(artifact.declarations.len(), 1);
417 assert_eq!(artifact.declarations[0].endpoint.package, "profile-service");
418 assert_eq!(artifact.declarations[0].endpoint.export, "loadProfile");
419 assert_eq!(
420 artifact.declarations[0].data_codec,
421 crate::ResumeValueCodec::StringCodec
422 );
423 assert_eq!(
424 artifact.declarations[0].error_codec,
425 crate::ResumeValueCodec::StringCodec
426 );
427 assert_eq!(artifact.activations.len(), 1);
428 assert_eq!(artifact.activations[0].state, "idle");
429 assert!(validate_runtime_resource_artifact(&artifact).is_empty());
430 assert_eq!(
431 runtime_resource_artifact_json(&artifact),
432 runtime_resource_artifact_json(&build_runtime_resource_artifact(&model))
433 );
434 }
435
436 #[test]
437 fn rejects_malformed_resource_artifact_identity_and_lifecycle_records() {
438 let model = model();
439 let mut artifact = build_runtime_resource_artifact(&model);
440 artifact.schema_version = 9;
441 artifact.declarations[0].endpoint.integrity.clear();
442 artifact.declarations[0].data_codec = crate::ResumeValueCodec::ObjectCodec(vec![
443 crate::ResumeObjectPropertyCodec {
444 name: "duplicate".to_string(),
445 codec: crate::ResumeValueCodec::StringCodec,
446 },
447 crate::ResumeObjectPropertyCodec {
448 name: "duplicate".to_string(),
449 codec: crate::ResumeValueCodec::StringCodec,
450 },
451 ]);
452 artifact.activations[0].declaration = "resource:missing".to_string();
453 artifact.activations[0].data_slot = "fabricated:slot".to_string();
454 artifact.activations[0].state = "ready".to_string();
455
456 let errors = validate_runtime_resource_artifact(&artifact);
457 assert!(errors.iter().any(|error| matches!(
458 error,
459 crate::RuntimeResourceArtifactValidationError::UnsupportedSchemaVersion { actual: 9 }
460 )));
461 assert!(errors.iter().any(|error| matches!(
462 error,
463 crate::RuntimeResourceArtifactValidationError::MissingEndpointCoordinate { .. }
464 )));
465 assert!(errors.iter().any(|error| matches!(
466 error,
467 crate::RuntimeResourceArtifactValidationError::InvalidValueCodec { .. }
468 )));
469 assert!(errors.iter().any(|error| matches!(
470 error,
471 crate::RuntimeResourceArtifactValidationError::InvalidLifecycleGeneration { .. }
472 )));
473 assert!(errors.iter().any(|error| matches!(
474 error,
475 crate::RuntimeResourceArtifactValidationError::UnknownActivationDeclaration { .. }
476 )));
477 assert!(errors.iter().any(|error| matches!(
478 error,
479 crate::RuntimeResourceArtifactValidationError::InvalidResumeSlotIdentity { .. }
480 )));
481 }
482
483 #[test]
484 fn resource_artifact_and_resume_manifest_are_deterministic_under_source_reversal() {
485 let forward = ordered_model(false);
486 let reverse = ordered_model(true);
487 assert_eq!(
488 runtime_resource_artifact_json(&build_runtime_resource_artifact(&forward)),
489 runtime_resource_artifact_json(&build_runtime_resource_artifact(&reverse))
490 );
491 assert_eq!(
492 crate::resume_manifest_json(&crate::build_resume_manifest(&forward)),
493 crate::resume_manifest_json(&crate::build_resume_manifest(&reverse))
494 );
495 }
496
497 #[test]
498 fn requires_exact_runtime_module_location_for_execution_facing_artifact() {
499 let model = model();
500 let mut modules = SemanticPackageRuntimeModuleTable::default();
501 assert!(matches!(
502 build_runtime_resource_artifact_with_modules(&model, &modules),
503 Err(crate::RuntimeResourceArtifactBuildError::MissingRuntimeModuleLocation { .. })
504 ));
505 modules
506 .insert(
507 SemanticPackageRuntimeModuleKey {
508 package: "profile-service".into(),
509 version: "1.2.3".into(),
510 integrity:
511 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
512 .into(),
513 runtime_module: "dist/load-profile.js".into(),
514 },
515 "./vendor/profile-service.js".into(),
516 )
517 .unwrap();
518 let artifact = build_runtime_resource_artifact_with_modules(&model, &modules).unwrap();
519 assert_eq!(
520 artifact.declarations[0]
521 .endpoint
522 .runtime_location
523 .as_deref(),
524 Some("./vendor/profile-service.js")
525 );
526 }
527}