Skip to main content

presolve_compiler/
resume_schema.rs

1//! J6 exact per-boundary resume schemas and closed value codecs.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    build_computed_instance_slot_registry, build_resume_boundary_graph, build_resume_liveness_plan,
9    build_runtime_component_registry, build_state_instance_storage_registry,
10    lower_components_to_ir, ApplicationSemanticModel, ResumeBoundaryId, ResumeExistingSlot,
11    ResumeLivenessPlan, ResumeSchemaId, ResumeSlotId, SemanticType, SourceProvenance,
12};
13
14pub const RESUME_SCHEMA_REGISTRY_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17pub struct ResumeObjectPropertyCodec {
18    pub name: String,
19    pub codec: ResumeValueCodec,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
23#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
24pub enum ResumeValueCodec {
25    NullCodec,
26    BooleanCodec,
27    NumberCodec,
28    StringCodec,
29    ArrayCodec(Box<ResumeValueCodec>),
30    ObjectCodec(Vec<ResumeObjectPropertyCodec>),
31    NullableCodec(Box<ResumeValueCodec>),
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ResumeSlotSchema {
36    pub resume_slot_id: ResumeSlotId,
37    pub existing_slot: ResumeExistingSlot,
38    pub semantic_type: SemanticType,
39    pub codec: ResumeValueCodec,
40    pub provenance: SourceProvenance,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ResumeBoundarySchema {
45    pub id: ResumeSchemaId,
46    pub boundary: ResumeBoundaryId,
47    pub slots: Vec<ResumeSlotSchema>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
51pub enum ResumeSchemaBlockReason {
52    UpstreamLivenessBlock,
53    MissingCanonicalSlotType,
54    MalformedSemanticType,
55    UnsupportedValue,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ResumeSchemaBlock {
60    pub boundary: Option<ResumeBoundaryId>,
61    pub slot: ResumeExistingSlot,
62    pub reason: ResumeSchemaBlockReason,
63    pub semantic_type: Option<SemanticType>,
64    pub provenance: SourceProvenance,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ResumeSchemaRegistry {
69    pub version: u32,
70    pub schemas: Vec<ResumeBoundarySchema>,
71    pub blocks: Vec<ResumeSchemaBlock>,
72    pub schema_index: BTreeMap<ResumeBoundaryId, usize>,
73    pub slot_index: BTreeMap<ResumeExistingSlot, ResumeSchemaId>,
74}
75
76impl ResumeSchemaRegistry {
77    #[must_use]
78    pub fn schema(&self, boundary: &ResumeBoundaryId) -> Option<&ResumeBoundarySchema> {
79        self.schema_index
80            .get(boundary)
81            .and_then(|index| self.schemas.get(*index))
82    }
83
84    #[must_use]
85    pub fn schema_for_slot(&self, slot: &ResumeExistingSlot) -> Option<&ResumeBoundarySchema> {
86        self.slot_index.get(slot).and_then(|schema| {
87            self.schemas
88                .iter()
89                .find(|candidate| &candidate.id == schema)
90        })
91    }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
95pub enum ResumeSchemaIntegrityCode {
96    MalformedSemanticType,
97    DuplicateProperty,
98    UnsupportedValue,
99    MissingSlot,
100    IdentityCollision,
101    OrderingOrIndexDrift,
102}
103
104impl ResumeSchemaIntegrityCode {
105    #[must_use]
106    pub const fn code(self) -> &'static str {
107        match self {
108            Self::MalformedSemanticType => "PSASM1349",
109            Self::DuplicateProperty => "PSASM1350",
110            Self::UnsupportedValue => "PSASM1351",
111            Self::MissingSlot => "PSASM1352",
112            Self::IdentityCollision => "PSASM1353",
113            Self::OrderingOrIndexDrift => "PSASM1354",
114        }
115    }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ResumeSchemaIntegrityDiagnostic {
120    pub code: ResumeSchemaIntegrityCode,
121    pub boundary: Option<ResumeBoundaryId>,
122    pub slot: Option<ResumeExistingSlot>,
123    pub message: String,
124}
125
126/// # Errors
127///
128/// Returns a closed schema-block reason when the semantic type is malformed or
129/// cannot be represented by the J6 codec vocabulary.
130pub fn resume_value_codec(
131    semantic_type: &SemanticType,
132) -> Result<ResumeValueCodec, ResumeSchemaBlockReason> {
133    match semantic_type {
134        SemanticType::Null => Ok(ResumeValueCodec::NullCodec),
135        SemanticType::Boolean | SemanticType::BooleanLiteral(_) => {
136            Ok(ResumeValueCodec::BooleanCodec)
137        }
138        SemanticType::Number | SemanticType::NumberLiteral(_) => Ok(ResumeValueCodec::NumberCodec),
139        SemanticType::String | SemanticType::StringLiteral(_) => Ok(ResumeValueCodec::StringCodec),
140        SemanticType::Array(element) => resume_value_codec(element)
141            .map(Box::new)
142            .map(ResumeValueCodec::ArrayCodec),
143        SemanticType::Object(object) => object
144            .properties
145            .iter()
146            .map(|(name, property)| {
147                Ok(ResumeObjectPropertyCodec {
148                    name: name.clone(),
149                    codec: resume_value_codec(property)?,
150                })
151            })
152            .collect::<Result<Vec<_>, ResumeSchemaBlockReason>>()
153            .map(ResumeValueCodec::ObjectCodec),
154        SemanticType::Union(members) => nullable_codec(members),
155        SemanticType::Unknown
156        | SemanticType::Never
157        | SemanticType::Form
158        | SemanticType::SlotContent => Err(ResumeSchemaBlockReason::MalformedSemanticType),
159        SemanticType::File => Err(ResumeSchemaBlockReason::UnsupportedValue),
160        SemanticType::Tuple(_) | SemanticType::Resource(_) => {
161            Err(ResumeSchemaBlockReason::UnsupportedValue)
162        }
163    }
164}
165
166fn nullable_codec(members: &[SemanticType]) -> Result<ResumeValueCodec, ResumeSchemaBlockReason> {
167    if members.is_empty() {
168        return Err(ResumeSchemaBlockReason::MalformedSemanticType);
169    }
170    let non_null = members
171        .iter()
172        .filter(|member| !matches!(member, SemanticType::Null))
173        .collect::<Vec<_>>();
174    let null_count = members.len() - non_null.len();
175    if null_count != 1 || non_null.len() != 1 {
176        return Err(ResumeSchemaBlockReason::UnsupportedValue);
177    }
178    resume_value_codec(non_null[0])
179        .map(Box::new)
180        .map(ResumeValueCodec::NullableCodec)
181}
182
183#[must_use]
184#[allow(clippy::too_many_lines)]
185pub fn build_resume_schema_registry(model: &ApplicationSemanticModel) -> ResumeSchemaRegistry {
186    let boundaries = build_resume_boundary_graph(model);
187    let liveness = build_resume_liveness_plan(model);
188    let slot_types = ResumeSlotTypeAuthority::new(model);
189    let mut schemas = boundaries
190        .boundaries
191        .iter()
192        .map(|boundary| ResumeBoundarySchema {
193            id: ResumeSchemaId::for_boundary(&boundary.id),
194            boundary: boundary.id.clone(),
195            slots: Vec::new(),
196        })
197        .collect::<Vec<_>>();
198    let schema_index = schemas
199        .iter()
200        .enumerate()
201        .map(|(index, schema)| (schema.boundary.clone(), index))
202        .collect::<BTreeMap<_, _>>();
203    let mut blocks = Vec::new();
204
205    for slot in capture_eligible_slots(&liveness) {
206        let boundary = slot.boundary_candidate.clone();
207        let Some(boundary_id) = boundary.as_ref() else {
208            blocks.push(ResumeSchemaBlock {
209                boundary,
210                slot: slot.existing_slot.clone(),
211                reason: ResumeSchemaBlockReason::MissingCanonicalSlotType,
212                semantic_type: None,
213                provenance: slot.provenance.clone(),
214            });
215            continue;
216        };
217        let Some(index) = schema_index.get(boundary_id).copied() else {
218            blocks.push(ResumeSchemaBlock {
219                boundary,
220                slot: slot.existing_slot.clone(),
221                reason: ResumeSchemaBlockReason::MissingCanonicalSlotType,
222                semantic_type: None,
223                provenance: slot.provenance.clone(),
224            });
225            continue;
226        };
227        let Some(semantic_type) = slot_types.semantic_type(&slot.existing_slot) else {
228            blocks.push(ResumeSchemaBlock {
229                boundary: Some(boundary_id.clone()),
230                slot: slot.existing_slot.clone(),
231                reason: ResumeSchemaBlockReason::MissingCanonicalSlotType,
232                semantic_type: None,
233                provenance: slot.provenance.clone(),
234            });
235            continue;
236        };
237        match resume_value_codec(&semantic_type) {
238            Ok(codec) => schemas[index].slots.push(ResumeSlotSchema {
239                resume_slot_id: slot.resume_slot_id.clone(),
240                existing_slot: slot.existing_slot.clone(),
241                semantic_type,
242                codec,
243                provenance: slot.provenance.clone(),
244            }),
245            Err(reason) => blocks.push(ResumeSchemaBlock {
246                boundary: Some(boundary_id.clone()),
247                slot: slot.existing_slot.clone(),
248                reason,
249                semantic_type: Some(semantic_type),
250                provenance: slot.provenance.clone(),
251            }),
252        }
253    }
254
255    for blocked in &liveness.blocked {
256        blocks.push(ResumeSchemaBlock {
257            boundary: blocked.slot.boundary_candidate.clone(),
258            slot: blocked.slot.existing_slot.clone(),
259            reason: ResumeSchemaBlockReason::UpstreamLivenessBlock,
260            semantic_type: slot_types.semantic_type(&blocked.slot.existing_slot),
261            provenance: blocked.slot.provenance.clone(),
262        });
263    }
264
265    for schema in &mut schemas {
266        schema.slots.sort_by(|left, right| {
267            (&left.resume_slot_id, &left.existing_slot)
268                .cmp(&(&right.resume_slot_id, &right.existing_slot))
269        });
270    }
271    blocks.sort_by(|left, right| {
272        (&left.boundary, &left.slot, left.reason).cmp(&(&right.boundary, &right.slot, right.reason))
273    });
274    let slot_index = schemas
275        .iter()
276        .flat_map(|schema| {
277            schema
278                .slots
279                .iter()
280                .map(|slot| (slot.existing_slot.clone(), schema.id.clone()))
281        })
282        .collect();
283
284    ResumeSchemaRegistry {
285        version: RESUME_SCHEMA_REGISTRY_VERSION,
286        schemas,
287        blocks,
288        schema_index,
289        slot_index,
290    }
291}
292
293fn capture_eligible_slots(liveness: &ResumeLivenessPlan) -> Vec<&crate::ResumeLivenessSlot> {
294    let mut slots = liveness
295        .retained
296        .iter()
297        .map(|record| &record.slot)
298        .chain(liveness.recomputable.iter().map(|record| &record.slot))
299        .collect::<Vec<_>>();
300    slots.sort_by(|left, right| left.existing_slot.cmp(&right.existing_slot));
301    slots
302}
303
304struct ResumeSlotTypeAuthority {
305    types: BTreeMap<ResumeExistingSlot, SemanticType>,
306}
307
308impl ResumeSlotTypeAuthority {
309    fn new(model: &ApplicationSemanticModel) -> Self {
310        let ir = lower_components_to_ir(model);
311        let mut types = BTreeMap::new();
312        for record in build_state_instance_storage_registry(model, &ir).records {
313            types.insert(
314                ResumeExistingSlot::State(record.slot_id),
315                record.semantic_type,
316            );
317        }
318        for activation in model.resource_activations.values() {
319            let Some(declaration) = model.resource_declarations.get(&activation.declaration) else {
320                continue;
321            };
322            let snapshot_policy = model
323                .resource_endpoint_resolutions
324                .iter()
325                .any(|resolution| {
326                    resolution.owner_component == declaration.owner_component
327                        && resolution.field == declaration.name
328                        && matches!(
329                            resolution.outcome,
330                            crate::ResourceEndpointResolutionOutcome::Resolved(ref endpoint)
331                                if matches!(
332                                    endpoint.endpoint.resume,
333                                    crate::SemanticPackageResourceResumePolicy::Snapshot
334                                )
335                        )
336                });
337            if !snapshot_policy {
338                continue;
339            }
340            types.insert(
341                ResumeExistingSlot::ResourceState(activation.id.state_slot()),
342                SemanticType::Object(crate::ObjectType {
343                    properties: BTreeMap::from([
344                        ("generation".to_string(), SemanticType::Number),
345                        ("state".to_string(), SemanticType::String),
346                    ]),
347                }),
348            );
349            types.insert(
350                ResumeExistingSlot::ResourceData(activation.id.data_slot()),
351                SemanticType::Union(vec![SemanticType::Null, declaration.data_type.clone()]),
352            );
353            types.insert(
354                ResumeExistingSlot::ResourceError(activation.id.error_slot()),
355                SemanticType::Union(vec![SemanticType::Null, declaration.error_type.clone()]),
356            );
357        }
358        for record in build_computed_instance_slot_registry(model, &ir).records {
359            if let Some(semantic_type) = model.semantic_type_of(&record.computed_id) {
360                types.insert(
361                    ResumeExistingSlot::ComputedCache(record.cache_slot_id),
362                    semantic_type.clone(),
363                );
364            }
365            types.insert(
366                ResumeExistingSlot::ComputedDirty(record.dirty_slot_id),
367                SemanticType::Boolean,
368            );
369        }
370        let runtime_components =
371            build_runtime_component_registry(model, &model.component_ir_optimization);
372        for binding in runtime_components.instance_context_bindings {
373            if let Some(semantic_type) =
374                model.semantic_type_of(binding.selected_source.context.as_semantic_id())
375            {
376                types.insert(
377                    ResumeExistingSlot::Context(binding.runtime_slot),
378                    semantic_type.clone(),
379                );
380            }
381        }
382        for instance in model.optimized_form_ir.optimized.instances.values() {
383            for (field_id, slot) in &instance.storage.value {
384                if let Some(field) = model.form_fields.get(field_id) {
385                    types.insert(
386                        ResumeExistingSlot::FormFieldValue(slot.clone()),
387                        field.semantic_type.clone(),
388                    );
389                }
390            }
391            for slot in instance.storage.dirty.values() {
392                types.insert(
393                    ResumeExistingSlot::FormFieldDirty(slot.clone()),
394                    SemanticType::Boolean,
395                );
396            }
397            for slot in instance.storage.touched.values() {
398                types.insert(
399                    ResumeExistingSlot::FormFieldTouched(slot.clone()),
400                    SemanticType::Boolean,
401                );
402            }
403            for slot in instance.storage.validation.values() {
404                types.insert(
405                    ResumeExistingSlot::FormFieldValidation(slot.clone()),
406                    SemanticType::Array(Box::new(SemanticType::String)),
407                );
408            }
409            types.insert(
410                ResumeExistingSlot::FormValidationAggregate(instance.storage.aggregate.clone()),
411                SemanticType::Boolean,
412            );
413            types.insert(
414                ResumeExistingSlot::FormSubmission(instance.storage.submission.clone()),
415                SemanticType::String,
416            );
417        }
418        Self { types }
419    }
420
421    fn semantic_type(&self, slot: &ResumeExistingSlot) -> Option<SemanticType> {
422        self.types.get(slot).cloned()
423    }
424}
425
426/// # Errors
427///
428/// Returns exact J6 integrity diagnostics for malformed codecs, missing schema
429/// reciprocity, identity collisions, or canonical ordering/index drift.
430pub fn validate_resume_schema_registry(
431    model: &ApplicationSemanticModel,
432    registry: &ResumeSchemaRegistry,
433) -> Result<(), Vec<ResumeSchemaIntegrityDiagnostic>> {
434    let mut diagnostics = Vec::new();
435    let boundaries = build_resume_boundary_graph(model);
436    let liveness = build_resume_liveness_plan(model);
437    let expected_boundary_order = boundaries
438        .boundaries
439        .iter()
440        .map(|boundary| boundary.id.clone())
441        .collect::<Vec<_>>();
442    let actual_boundary_order = registry
443        .schemas
444        .iter()
445        .map(|schema| schema.boundary.clone())
446        .collect::<Vec<_>>();
447    if expected_boundary_order != actual_boundary_order {
448        diagnostics.push(diagnostic(
449            ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
450            None,
451            None,
452            "resume schemas do not preserve canonical parent-before-child boundary order",
453        ));
454    }
455
456    let mut schema_ids = BTreeSet::new();
457    let mut resume_slot_ids = BTreeSet::new();
458    let mut existing_slots = BTreeSet::new();
459    for schema in &registry.schemas {
460        if schema.id != ResumeSchemaId::for_boundary(&schema.boundary)
461            || !schema_ids.insert(schema.id.clone())
462        {
463            diagnostics.push(diagnostic(
464                ResumeSchemaIntegrityCode::IdentityCollision,
465                Some(schema.boundary.clone()),
466                None,
467                "resume schema identity is not unique and boundary-derived",
468            ));
469        }
470        for slot in &schema.slots {
471            if slot.resume_slot_id != slot.existing_slot.resume_slot_id()
472                || !resume_slot_ids.insert(slot.resume_slot_id.clone())
473                || !existing_slots.insert(slot.existing_slot.clone())
474            {
475                diagnostics.push(diagnostic(
476                    ResumeSchemaIntegrityCode::IdentityCollision,
477                    Some(schema.boundary.clone()),
478                    Some(slot.existing_slot.clone()),
479                    "resume slot identity is not unique and storage-derived",
480                ));
481            }
482            validate_codec(
483                &slot.codec,
484                &schema.boundary,
485                &slot.existing_slot,
486                &mut diagnostics,
487            );
488        }
489    }
490
491    let expected_slots = capture_eligible_slots(&liveness)
492        .into_iter()
493        .map(|slot| slot.existing_slot.clone())
494        .collect::<BTreeSet<_>>();
495    let blocked_slots = registry
496        .blocks
497        .iter()
498        .map(|block| block.slot.clone())
499        .collect::<BTreeSet<_>>();
500    if !expected_slots
501        .iter()
502        .all(|slot| existing_slots.contains(slot) || blocked_slots.contains(slot))
503    {
504        diagnostics.push(diagnostic(
505            ResumeSchemaIntegrityCode::MissingSlot,
506            None,
507            None,
508            "capture-eligible liveness slot lacks a schema or explicit schema block",
509        ));
510    }
511    if registry.version != RESUME_SCHEMA_REGISTRY_VERSION
512        || registry != &build_resume_schema_registry(model)
513    {
514        diagnostics.push(diagnostic(
515            ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
516            None,
517            None,
518            "resume schema registry drifted from canonical ordering or indexes",
519        ));
520    }
521
522    if diagnostics.is_empty() {
523        Ok(())
524    } else {
525        Err(diagnostics)
526    }
527}
528
529fn validate_codec(
530    codec: &ResumeValueCodec,
531    boundary: &ResumeBoundaryId,
532    slot: &ResumeExistingSlot,
533    diagnostics: &mut Vec<ResumeSchemaIntegrityDiagnostic>,
534) {
535    match codec {
536        ResumeValueCodec::ArrayCodec(element) | ResumeValueCodec::NullableCodec(element) => {
537            validate_codec(element, boundary, slot, diagnostics);
538        }
539        ResumeValueCodec::ObjectCodec(properties) => {
540            let names = properties
541                .iter()
542                .map(|property| property.name.as_str())
543                .collect::<Vec<_>>();
544            if names.windows(2).any(|pair| pair[0] == pair[1]) {
545                diagnostics.push(diagnostic(
546                    ResumeSchemaIntegrityCode::DuplicateProperty,
547                    Some(boundary.clone()),
548                    Some(slot.clone()),
549                    "resume object codec contains a duplicate property",
550                ));
551            }
552            if names.windows(2).any(|pair| pair[0] > pair[1]) {
553                diagnostics.push(diagnostic(
554                    ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
555                    Some(boundary.clone()),
556                    Some(slot.clone()),
557                    "resume object codec properties are not in canonical order",
558                ));
559            }
560            for property in properties {
561                validate_codec(&property.codec, boundary, slot, diagnostics);
562            }
563        }
564        ResumeValueCodec::NullCodec
565        | ResumeValueCodec::BooleanCodec
566        | ResumeValueCodec::NumberCodec
567        | ResumeValueCodec::StringCodec => {}
568    }
569}
570
571fn diagnostic(
572    code: ResumeSchemaIntegrityCode,
573    boundary: Option<ResumeBoundaryId>,
574    slot: Option<ResumeExistingSlot>,
575    message: &str,
576) -> ResumeSchemaIntegrityDiagnostic {
577    ResumeSchemaIntegrityDiagnostic {
578        code,
579        boundary,
580        slot,
581        message: message.to_string(),
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::{
589        build_application_semantic_model_for_unit_with_packages, parse_semantic_package_contract,
590        CompilationUnit, ObjectType, SemanticPackageResolutionTable,
591    };
592
593    fn model(source: &str) -> ApplicationSemanticModel {
594        crate::build_application_semantic_model(&presolve_parser::parse_file(
595            "src/ResumeSchema.tsx",
596            source,
597        ))
598    }
599
600    fn resource_model(resume_policy: &str) -> ApplicationSemanticModel {
601        let unit = CompilationUnit::parse_sources([(
602            "src/Profile.tsx",
603            r#"
604import { loadProfile } from "profile-service";
605@component("x-profile") @route("/") class Profile extends Component {
606  @resource("loadProfile") profile!: Resource<string, string>;
607  @computed() get profileName(): string | null { return this.profile.data; }
608  render() { return <main>{this.profileName}</main>; }
609}
610"#,
611        )]);
612        let contract = parse_semantic_package_contract(&format!(
613            r#"{{"schema_version":1,"package":"profile-service","version":"1.2.3","integrity":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","exports":{{"loadProfile":{{"kind":"resource","type_signature":"() -> Resource<string, string>","runtime_module":"dist/load-profile.js","resume_policy":"{resume_policy}","resource_endpoint":{{"execution_boundary":"shared","cancellation":"abort","resume":"{resume_policy}"}}}}}}}}"#
614        ))
615        .expect("resource contract");
616        let mut packages = SemanticPackageResolutionTable::default();
617        packages.insert("profile-service".into(), contract).unwrap();
618        build_application_semantic_model_for_unit_with_packages(&unit, &packages)
619    }
620
621    #[test]
622    fn derives_closed_codecs_with_canonical_object_order_and_explicit_nullable() {
623        let object = SemanticType::Object(ObjectType {
624            properties: BTreeMap::from([
625                ("b".to_string(), SemanticType::String),
626                ("a".to_string(), SemanticType::Number),
627            ]),
628        });
629        assert_eq!(
630            resume_value_codec(&object),
631            Ok(ResumeValueCodec::ObjectCodec(vec![
632                ResumeObjectPropertyCodec {
633                    name: "a".to_string(),
634                    codec: ResumeValueCodec::NumberCodec,
635                },
636                ResumeObjectPropertyCodec {
637                    name: "b".to_string(),
638                    codec: ResumeValueCodec::StringCodec,
639                },
640            ]))
641        );
642        assert_eq!(
643            resume_value_codec(&SemanticType::Union(vec![
644                SemanticType::Null,
645                SemanticType::String,
646            ])),
647            Ok(ResumeValueCodec::NullableCodec(Box::new(
648                ResumeValueCodec::StringCodec
649            )))
650        );
651        assert_eq!(
652            resume_value_codec(&SemanticType::Union(vec![
653                SemanticType::String,
654                SemanticType::Number,
655            ])),
656            Err(ResumeSchemaBlockReason::UnsupportedValue)
657        );
658    }
659
660    #[test]
661    fn creates_one_schema_per_boundary_with_exact_slot_reciprocity() {
662        let model = model(
663            r#"@component("x-counter") class Counter { count = state(1); render() { return <button>{this.count}</button>; } }"#,
664        );
665        let graph = build_resume_boundary_graph(&model);
666        let liveness = build_resume_liveness_plan(&model);
667        let registry = build_resume_schema_registry(&model);
668        assert_eq!(registry.schemas.len(), graph.boundaries.len());
669        assert_eq!(
670            registry
671                .schemas
672                .iter()
673                .map(|schema| &schema.boundary)
674                .collect::<Vec<_>>(),
675            graph
676                .boundaries
677                .iter()
678                .map(|boundary| &boundary.id)
679                .collect::<Vec<_>>()
680        );
681        let expected = capture_eligible_slots(&liveness)
682            .into_iter()
683            .map(|slot| slot.existing_slot.clone())
684            .collect::<BTreeSet<_>>();
685        let actual = registry.slot_index.keys().cloned().collect::<BTreeSet<_>>();
686        assert_eq!(actual, expected);
687        assert!(registry.blocks.is_empty());
688        assert_eq!(validate_resume_schema_registry(&model, &registry), Ok(()));
689    }
690
691    #[test]
692    fn freezes_form_runtime_slot_codecs_without_runtime_guessing() {
693        let model = model(
694            r#"@component("x-profile") class Profile {
695  @form() profile!: Form;
696  @field(this.profile) name = "";
697  render() { return <input field={this.name} />; }
698}"#,
699        );
700        let registry = build_resume_schema_registry(&model);
701        let codecs = registry
702            .schemas
703            .iter()
704            .flat_map(|schema| &schema.slots)
705            .map(|slot| (&slot.existing_slot, &slot.codec))
706            .collect::<BTreeMap<_, _>>();
707        assert!(codecs.iter().any(|(slot, codec)| matches!(
708            (slot, codec),
709            (
710                ResumeExistingSlot::FormFieldValidation(_),
711                ResumeValueCodec::ArrayCodec(element)
712            ) if **element == ResumeValueCodec::StringCodec
713        )));
714        assert!(codecs.iter().any(|(slot, codec)| matches!(
715            (slot, codec),
716            (
717                ResumeExistingSlot::FormValidationAggregate(_),
718                ResumeValueCodec::BooleanCodec
719            )
720        )));
721        assert!(codecs.iter().any(|(slot, codec)| matches!(
722            (slot, codec),
723            (
724                ResumeExistingSlot::FormSubmission(_),
725                ResumeValueCodec::StringCodec
726            )
727        )));
728    }
729
730    #[test]
731    fn snapshot_resources_publish_instance_qualified_terminal_slots_before_computed_recompute() {
732        let model = resource_model("snapshot");
733        let liveness = build_resume_liveness_plan(&model);
734        let resource_slots =
735            liveness.slots_for_reason(crate::ResumeRetentionReason::ResourceSnapshotValue);
736        assert_eq!(resource_slots.len(), 3);
737        assert!(resource_slots.iter().all(|slot| matches!(
738            slot.existing_slot,
739            ResumeExistingSlot::ResourceState(_)
740                | ResumeExistingSlot::ResourceData(_)
741                | ResumeExistingSlot::ResourceError(_)
742        )));
743        let computed = liveness
744            .recomputable
745            .iter()
746            .find(|record| {
747                matches!(
748                    record.slot.existing_slot,
749                    ResumeExistingSlot::ComputedCache(_)
750                )
751            })
752            .expect("profile computed cache");
753        assert!(matches!(
754            computed.slot.direct_dependencies.as_slice(),
755            [ResumeExistingSlot::ResourceData(_)]
756        ));
757
758        let registry = build_resume_schema_registry(&model);
759        let codecs = registry
760            .schemas
761            .iter()
762            .flat_map(|schema| &schema.slots)
763            .map(|slot| (&slot.existing_slot, &slot.codec))
764            .collect::<BTreeMap<_, _>>();
765        assert!(codecs.iter().any(|(slot, codec)| matches!(
766            (slot, codec),
767            (
768                ResumeExistingSlot::ResourceState(_),
769                ResumeValueCodec::ObjectCodec(properties)
770            ) if properties == &vec![
771                ResumeObjectPropertyCodec {
772                    name: "generation".to_string(),
773                    codec: ResumeValueCodec::NumberCodec,
774                },
775                ResumeObjectPropertyCodec {
776                    name: "state".to_string(),
777                    codec: ResumeValueCodec::StringCodec,
778                },
779            ]
780        )));
781        assert_eq!(
782            codecs
783                .iter()
784                .filter(|(slot, codec)| matches!(
785                    (slot, codec),
786                    (ResumeExistingSlot::ResourceData(_), ResumeValueCodec::NullableCodec(inner))
787                        if **inner == ResumeValueCodec::StringCodec
788                ) || matches!(
789                    (slot, codec),
790                    (ResumeExistingSlot::ResourceError(_), ResumeValueCodec::NullableCodec(inner))
791                        if **inner == ResumeValueCodec::StringCodec
792                ))
793                .count(),
794            2
795        );
796        let resource_slot_ids = registry
797            .schemas
798            .iter()
799            .flat_map(|schema| &schema.slots)
800            .filter(|slot| {
801                matches!(
802                    slot.existing_slot,
803                    ResumeExistingSlot::ResourceState(_)
804                        | ResumeExistingSlot::ResourceData(_)
805                        | ResumeExistingSlot::ResourceError(_)
806                )
807            })
808            .map(|slot| slot.resume_slot_id.clone())
809            .collect::<BTreeSet<_>>();
810        let capture = crate::build_resume_capture_plan(&model);
811        let captured = capture
812            .programs
813            .iter()
814            .flat_map(|program| &program.instructions)
815            .filter_map(|instruction| match instruction {
816                crate::ResumeCaptureInstruction::ReadSlot { slot_id } => Some(slot_id.clone()),
817                _ => None,
818            })
819            .collect::<BTreeSet<_>>();
820        assert!(resource_slot_ids.is_subset(&captured));
821        let restore = crate::build_resume_restore_plan(&model);
822        assert_eq!(
823            restore
824                .programs
825                .iter()
826                .flat_map(|program| &program.slot_assignments)
827                .filter(|assignment| matches!(
828                    assignment.slot,
829                    ResumeExistingSlot::ResourceState(_)
830                        | ResumeExistingSlot::ResourceData(_)
831                        | ResumeExistingSlot::ResourceError(_)
832                ))
833                .map(|assignment| assignment.phase)
834                .collect::<BTreeSet<_>>(),
835            BTreeSet::from([crate::ResumeRestorePhase::R3RestoreMutableStateAndResources])
836        );
837        let manifest = crate::build_resume_manifest(&model);
838        assert_eq!(
839            manifest
840                .slot_schemas
841                .iter()
842                .filter(|slot| matches!(
843                    slot.retention_reason,
844                    crate::resume_manifest::ResumeManifestRetentionReason::SerializableResourceValue
845                ))
846                .count(),
847            3
848        );
849        assert!(validate_resume_schema_registry(&model, &registry).is_ok());
850    }
851
852    #[test]
853    fn reload_resources_do_not_publish_snapshot_slots() {
854        let model = resource_model("reload");
855        let liveness = build_resume_liveness_plan(&model);
856        assert!(liveness
857            .slots_for_reason(crate::ResumeRetentionReason::ResourceSnapshotValue)
858            .is_empty());
859        assert!(build_resume_schema_registry(&model)
860            .schemas
861            .iter()
862            .flat_map(|schema| &schema.slots)
863            .all(|slot| !matches!(
864                slot.existing_slot,
865                ResumeExistingSlot::ResourceState(_)
866                    | ResumeExistingSlot::ResourceData(_)
867                    | ResumeExistingSlot::ResourceError(_)
868            )));
869    }
870
871    #[test]
872    fn integrity_rejects_identity_and_ordering_drift() {
873        let model = model(
874            r#"@component("x-counter") class Counter { count = state(1); render() { return <button>{this.count}</button>; } }"#,
875        );
876        let mut registry = build_resume_schema_registry(&model);
877        registry.schemas.reverse();
878        let diagnostics = validate_resume_schema_registry(&model, &registry).expect_err("drift");
879        assert!(diagnostics.iter().any(|diagnostic| {
880            diagnostic.code == ResumeSchemaIntegrityCode::OrderingOrIndexDrift
881        }));
882    }
883
884    #[test]
885    fn schema_registry_is_deterministic_under_source_reversal() {
886        let first = presolve_parser::parse_file(
887            "src/A.tsx",
888            r#"@component("x-a") @route("/a") class A { value = state({ b: "two", a: 1 }); render() { return <button>{this.value}</button>; } }"#,
889        );
890        let second = presolve_parser::parse_file(
891            "src/B.tsx",
892            r#"@component("x-b") @route("/b") class B { enabled = state(true); render() { return <button>{this.enabled}</button>; } }"#,
893        );
894        let forward = crate::build_application_semantic_model_for_unit(
895            &crate::CompilationUnit::from_parsed_files(vec![first.clone(), second.clone()]),
896        );
897        let reverse = crate::build_application_semantic_model_for_unit(
898            &crate::CompilationUnit::from_parsed_files(vec![second, first]),
899        );
900        assert_eq!(
901            build_resume_schema_registry(&forward),
902            build_resume_schema_registry(&reverse)
903        );
904    }
905
906    #[test]
907    fn schema_order_keeps_nested_boundaries_parent_before_child() {
908        let model = model(
909            r#"@component("x-child") class Child { value = state(1); render() { return <span>{this.value}</span>; } }
910@component("x-parent") @route("/") class Parent { render() { return <Child />; } }"#,
911        );
912        let graph = build_resume_boundary_graph(&model);
913        let registry = build_resume_schema_registry(&model);
914        assert_eq!(
915            registry
916                .schemas
917                .iter()
918                .map(|schema| &schema.boundary)
919                .collect::<Vec<_>>(),
920            graph
921                .boundaries
922                .iter()
923                .map(|boundary| &boundary.id)
924                .collect::<Vec<_>>()
925        );
926    }
927
928    #[test]
929    fn reserves_the_complete_j6_integrity_range() {
930        assert_eq!(
931            [
932                ResumeSchemaIntegrityCode::MalformedSemanticType,
933                ResumeSchemaIntegrityCode::DuplicateProperty,
934                ResumeSchemaIntegrityCode::UnsupportedValue,
935                ResumeSchemaIntegrityCode::MissingSlot,
936                ResumeSchemaIntegrityCode::IdentityCollision,
937                ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
938            ]
939            .map(ResumeSchemaIntegrityCode::code),
940            [
941                "PSASM1349",
942                "PSASM1350",
943                "PSASM1351",
944                "PSASM1352",
945                "PSASM1353",
946                "PSASM1354",
947            ]
948        );
949    }
950}