Skip to main content

presolve_compiler/
runtime_resource_artifact.rs

1//! N6-C3 deterministic Resource declaration and activation artifact.
2//!
3//! This is a projection of already-resolved compiler products. Its
4//! execution-facing form contains the exact host-provided runtime module
5//! location consumed by the generated browser runtime.
6
7use 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
17/// Version 3 adds exact activation-to-resume-slot linkage for atomic browser
18/// snapshot restoration.
19pub 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/// Projects immutable Resource declaration and instance-activation records.
110///
111/// # Panics
112///
113/// Panics when an internal declaration has no resolved resource endpoint. That
114/// would violate the N6-C declaration-projection invariant.
115#[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
193/// Produces the execution-facing artifact only when every Resource endpoint
194/// has an explicit host-supplied location for its exact package coordinate.
195pub 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/// Validates the exact identity and endpoint prerequisites required by the
227/// Resource runtime boundary. Callers must reject the artifact on any error.
228#[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}