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::Tuple(_) | SemanticType::Resource(_) => {
160            Err(ResumeSchemaBlockReason::UnsupportedValue)
161        }
162    }
163}
164
165fn nullable_codec(members: &[SemanticType]) -> Result<ResumeValueCodec, ResumeSchemaBlockReason> {
166    if members.is_empty() {
167        return Err(ResumeSchemaBlockReason::MalformedSemanticType);
168    }
169    let non_null = members
170        .iter()
171        .filter(|member| !matches!(member, SemanticType::Null))
172        .collect::<Vec<_>>();
173    let null_count = members.len() - non_null.len();
174    if null_count != 1 || non_null.len() != 1 {
175        return Err(ResumeSchemaBlockReason::UnsupportedValue);
176    }
177    resume_value_codec(non_null[0])
178        .map(Box::new)
179        .map(ResumeValueCodec::NullableCodec)
180}
181
182#[must_use]
183#[allow(clippy::too_many_lines)]
184pub fn build_resume_schema_registry(model: &ApplicationSemanticModel) -> ResumeSchemaRegistry {
185    let boundaries = build_resume_boundary_graph(model);
186    let liveness = build_resume_liveness_plan(model);
187    let slot_types = ResumeSlotTypeAuthority::new(model);
188    let mut schemas = boundaries
189        .boundaries
190        .iter()
191        .map(|boundary| ResumeBoundarySchema {
192            id: ResumeSchemaId::for_boundary(&boundary.id),
193            boundary: boundary.id.clone(),
194            slots: Vec::new(),
195        })
196        .collect::<Vec<_>>();
197    let schema_index = schemas
198        .iter()
199        .enumerate()
200        .map(|(index, schema)| (schema.boundary.clone(), index))
201        .collect::<BTreeMap<_, _>>();
202    let mut blocks = Vec::new();
203
204    for slot in capture_eligible_slots(&liveness) {
205        let boundary = slot.boundary_candidate.clone();
206        let Some(boundary_id) = boundary.as_ref() else {
207            blocks.push(ResumeSchemaBlock {
208                boundary,
209                slot: slot.existing_slot.clone(),
210                reason: ResumeSchemaBlockReason::MissingCanonicalSlotType,
211                semantic_type: None,
212                provenance: slot.provenance.clone(),
213            });
214            continue;
215        };
216        let Some(index) = schema_index.get(boundary_id).copied() else {
217            blocks.push(ResumeSchemaBlock {
218                boundary,
219                slot: slot.existing_slot.clone(),
220                reason: ResumeSchemaBlockReason::MissingCanonicalSlotType,
221                semantic_type: None,
222                provenance: slot.provenance.clone(),
223            });
224            continue;
225        };
226        let Some(semantic_type) = slot_types.semantic_type(&slot.existing_slot) else {
227            blocks.push(ResumeSchemaBlock {
228                boundary: Some(boundary_id.clone()),
229                slot: slot.existing_slot.clone(),
230                reason: ResumeSchemaBlockReason::MissingCanonicalSlotType,
231                semantic_type: None,
232                provenance: slot.provenance.clone(),
233            });
234            continue;
235        };
236        match resume_value_codec(&semantic_type) {
237            Ok(codec) => schemas[index].slots.push(ResumeSlotSchema {
238                resume_slot_id: slot.resume_slot_id.clone(),
239                existing_slot: slot.existing_slot.clone(),
240                semantic_type,
241                codec,
242                provenance: slot.provenance.clone(),
243            }),
244            Err(reason) => blocks.push(ResumeSchemaBlock {
245                boundary: Some(boundary_id.clone()),
246                slot: slot.existing_slot.clone(),
247                reason,
248                semantic_type: Some(semantic_type),
249                provenance: slot.provenance.clone(),
250            }),
251        }
252    }
253
254    for blocked in &liveness.blocked {
255        blocks.push(ResumeSchemaBlock {
256            boundary: blocked.slot.boundary_candidate.clone(),
257            slot: blocked.slot.existing_slot.clone(),
258            reason: ResumeSchemaBlockReason::UpstreamLivenessBlock,
259            semantic_type: slot_types.semantic_type(&blocked.slot.existing_slot),
260            provenance: blocked.slot.provenance.clone(),
261        });
262    }
263
264    for schema in &mut schemas {
265        schema.slots.sort_by(|left, right| {
266            (&left.resume_slot_id, &left.existing_slot)
267                .cmp(&(&right.resume_slot_id, &right.existing_slot))
268        });
269    }
270    blocks.sort_by(|left, right| {
271        (&left.boundary, &left.slot, left.reason).cmp(&(&right.boundary, &right.slot, right.reason))
272    });
273    let slot_index = schemas
274        .iter()
275        .flat_map(|schema| {
276            schema
277                .slots
278                .iter()
279                .map(|slot| (slot.existing_slot.clone(), schema.id.clone()))
280        })
281        .collect();
282
283    ResumeSchemaRegistry {
284        version: RESUME_SCHEMA_REGISTRY_VERSION,
285        schemas,
286        blocks,
287        schema_index,
288        slot_index,
289    }
290}
291
292fn capture_eligible_slots(liveness: &ResumeLivenessPlan) -> Vec<&crate::ResumeLivenessSlot> {
293    let mut slots = liveness
294        .retained
295        .iter()
296        .map(|record| &record.slot)
297        .chain(liveness.recomputable.iter().map(|record| &record.slot))
298        .collect::<Vec<_>>();
299    slots.sort_by(|left, right| left.existing_slot.cmp(&right.existing_slot));
300    slots
301}
302
303struct ResumeSlotTypeAuthority {
304    types: BTreeMap<ResumeExistingSlot, SemanticType>,
305}
306
307impl ResumeSlotTypeAuthority {
308    fn new(model: &ApplicationSemanticModel) -> Self {
309        let ir = lower_components_to_ir(model);
310        let mut types = BTreeMap::new();
311        for record in build_state_instance_storage_registry(model, &ir).records {
312            types.insert(
313                ResumeExistingSlot::State(record.slot_id),
314                record.semantic_type,
315            );
316        }
317        for record in build_computed_instance_slot_registry(model, &ir).records {
318            if let Some(semantic_type) = model.semantic_type_of(&record.computed_id) {
319                types.insert(
320                    ResumeExistingSlot::ComputedCache(record.cache_slot_id),
321                    semantic_type.clone(),
322                );
323            }
324            types.insert(
325                ResumeExistingSlot::ComputedDirty(record.dirty_slot_id),
326                SemanticType::Boolean,
327            );
328        }
329        let runtime_components =
330            build_runtime_component_registry(model, &model.component_ir_optimization);
331        for binding in runtime_components.instance_context_bindings {
332            if let Some(semantic_type) =
333                model.semantic_type_of(binding.selected_source.context.as_semantic_id())
334            {
335                types.insert(
336                    ResumeExistingSlot::Context(binding.runtime_slot),
337                    semantic_type.clone(),
338                );
339            }
340        }
341        for instance in model.optimized_form_ir.optimized.instances.values() {
342            for (field_id, slot) in &instance.storage.value {
343                if let Some(field) = model.form_fields.get(field_id) {
344                    types.insert(
345                        ResumeExistingSlot::FormFieldValue(slot.clone()),
346                        field.semantic_type.clone(),
347                    );
348                }
349            }
350            for slot in instance.storage.dirty.values() {
351                types.insert(
352                    ResumeExistingSlot::FormFieldDirty(slot.clone()),
353                    SemanticType::Boolean,
354                );
355            }
356            for slot in instance.storage.touched.values() {
357                types.insert(
358                    ResumeExistingSlot::FormFieldTouched(slot.clone()),
359                    SemanticType::Boolean,
360                );
361            }
362            for slot in instance.storage.validation.values() {
363                types.insert(
364                    ResumeExistingSlot::FormFieldValidation(slot.clone()),
365                    SemanticType::Array(Box::new(SemanticType::String)),
366                );
367            }
368            types.insert(
369                ResumeExistingSlot::FormValidationAggregate(instance.storage.aggregate.clone()),
370                SemanticType::Boolean,
371            );
372            types.insert(
373                ResumeExistingSlot::FormSubmission(instance.storage.submission.clone()),
374                SemanticType::String,
375            );
376        }
377        Self { types }
378    }
379
380    fn semantic_type(&self, slot: &ResumeExistingSlot) -> Option<SemanticType> {
381        self.types.get(slot).cloned()
382    }
383}
384
385/// # Errors
386///
387/// Returns exact J6 integrity diagnostics for malformed codecs, missing schema
388/// reciprocity, identity collisions, or canonical ordering/index drift.
389pub fn validate_resume_schema_registry(
390    model: &ApplicationSemanticModel,
391    registry: &ResumeSchemaRegistry,
392) -> Result<(), Vec<ResumeSchemaIntegrityDiagnostic>> {
393    let mut diagnostics = Vec::new();
394    let boundaries = build_resume_boundary_graph(model);
395    let liveness = build_resume_liveness_plan(model);
396    let expected_boundary_order = boundaries
397        .boundaries
398        .iter()
399        .map(|boundary| boundary.id.clone())
400        .collect::<Vec<_>>();
401    let actual_boundary_order = registry
402        .schemas
403        .iter()
404        .map(|schema| schema.boundary.clone())
405        .collect::<Vec<_>>();
406    if expected_boundary_order != actual_boundary_order {
407        diagnostics.push(diagnostic(
408            ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
409            None,
410            None,
411            "resume schemas do not preserve canonical parent-before-child boundary order",
412        ));
413    }
414
415    let mut schema_ids = BTreeSet::new();
416    let mut resume_slot_ids = BTreeSet::new();
417    let mut existing_slots = BTreeSet::new();
418    for schema in &registry.schemas {
419        if schema.id != ResumeSchemaId::for_boundary(&schema.boundary)
420            || !schema_ids.insert(schema.id.clone())
421        {
422            diagnostics.push(diagnostic(
423                ResumeSchemaIntegrityCode::IdentityCollision,
424                Some(schema.boundary.clone()),
425                None,
426                "resume schema identity is not unique and boundary-derived",
427            ));
428        }
429        for slot in &schema.slots {
430            if slot.resume_slot_id != slot.existing_slot.resume_slot_id()
431                || !resume_slot_ids.insert(slot.resume_slot_id.clone())
432                || !existing_slots.insert(slot.existing_slot.clone())
433            {
434                diagnostics.push(diagnostic(
435                    ResumeSchemaIntegrityCode::IdentityCollision,
436                    Some(schema.boundary.clone()),
437                    Some(slot.existing_slot.clone()),
438                    "resume slot identity is not unique and storage-derived",
439                ));
440            }
441            validate_codec(
442                &slot.codec,
443                &schema.boundary,
444                &slot.existing_slot,
445                &mut diagnostics,
446            );
447        }
448    }
449
450    let expected_slots = capture_eligible_slots(&liveness)
451        .into_iter()
452        .map(|slot| slot.existing_slot.clone())
453        .collect::<BTreeSet<_>>();
454    let blocked_slots = registry
455        .blocks
456        .iter()
457        .map(|block| block.slot.clone())
458        .collect::<BTreeSet<_>>();
459    if !expected_slots
460        .iter()
461        .all(|slot| existing_slots.contains(slot) || blocked_slots.contains(slot))
462    {
463        diagnostics.push(diagnostic(
464            ResumeSchemaIntegrityCode::MissingSlot,
465            None,
466            None,
467            "capture-eligible liveness slot lacks a schema or explicit schema block",
468        ));
469    }
470    if registry.version != RESUME_SCHEMA_REGISTRY_VERSION
471        || registry != &build_resume_schema_registry(model)
472    {
473        diagnostics.push(diagnostic(
474            ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
475            None,
476            None,
477            "resume schema registry drifted from canonical ordering or indexes",
478        ));
479    }
480
481    if diagnostics.is_empty() {
482        Ok(())
483    } else {
484        Err(diagnostics)
485    }
486}
487
488fn validate_codec(
489    codec: &ResumeValueCodec,
490    boundary: &ResumeBoundaryId,
491    slot: &ResumeExistingSlot,
492    diagnostics: &mut Vec<ResumeSchemaIntegrityDiagnostic>,
493) {
494    match codec {
495        ResumeValueCodec::ArrayCodec(element) | ResumeValueCodec::NullableCodec(element) => {
496            validate_codec(element, boundary, slot, diagnostics);
497        }
498        ResumeValueCodec::ObjectCodec(properties) => {
499            let names = properties
500                .iter()
501                .map(|property| property.name.as_str())
502                .collect::<Vec<_>>();
503            if names.windows(2).any(|pair| pair[0] == pair[1]) {
504                diagnostics.push(diagnostic(
505                    ResumeSchemaIntegrityCode::DuplicateProperty,
506                    Some(boundary.clone()),
507                    Some(slot.clone()),
508                    "resume object codec contains a duplicate property",
509                ));
510            }
511            if names.windows(2).any(|pair| pair[0] > pair[1]) {
512                diagnostics.push(diagnostic(
513                    ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
514                    Some(boundary.clone()),
515                    Some(slot.clone()),
516                    "resume object codec properties are not in canonical order",
517                ));
518            }
519            for property in properties {
520                validate_codec(&property.codec, boundary, slot, diagnostics);
521            }
522        }
523        ResumeValueCodec::NullCodec
524        | ResumeValueCodec::BooleanCodec
525        | ResumeValueCodec::NumberCodec
526        | ResumeValueCodec::StringCodec => {}
527    }
528}
529
530fn diagnostic(
531    code: ResumeSchemaIntegrityCode,
532    boundary: Option<ResumeBoundaryId>,
533    slot: Option<ResumeExistingSlot>,
534    message: &str,
535) -> ResumeSchemaIntegrityDiagnostic {
536    ResumeSchemaIntegrityDiagnostic {
537        code,
538        boundary,
539        slot,
540        message: message.to_string(),
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::ObjectType;
548
549    fn model(source: &str) -> ApplicationSemanticModel {
550        crate::build_application_semantic_model(&presolve_parser::parse_file(
551            "src/ResumeSchema.tsx",
552            source,
553        ))
554    }
555
556    #[test]
557    fn derives_closed_codecs_with_canonical_object_order_and_explicit_nullable() {
558        let object = SemanticType::Object(ObjectType {
559            properties: BTreeMap::from([
560                ("b".to_string(), SemanticType::String),
561                ("a".to_string(), SemanticType::Number),
562            ]),
563        });
564        assert_eq!(
565            resume_value_codec(&object),
566            Ok(ResumeValueCodec::ObjectCodec(vec![
567                ResumeObjectPropertyCodec {
568                    name: "a".to_string(),
569                    codec: ResumeValueCodec::NumberCodec,
570                },
571                ResumeObjectPropertyCodec {
572                    name: "b".to_string(),
573                    codec: ResumeValueCodec::StringCodec,
574                },
575            ]))
576        );
577        assert_eq!(
578            resume_value_codec(&SemanticType::Union(vec![
579                SemanticType::Null,
580                SemanticType::String,
581            ])),
582            Ok(ResumeValueCodec::NullableCodec(Box::new(
583                ResumeValueCodec::StringCodec
584            )))
585        );
586        assert_eq!(
587            resume_value_codec(&SemanticType::Union(vec![
588                SemanticType::String,
589                SemanticType::Number,
590            ])),
591            Err(ResumeSchemaBlockReason::UnsupportedValue)
592        );
593    }
594
595    #[test]
596    fn creates_one_schema_per_boundary_with_exact_slot_reciprocity() {
597        let model = model(
598            r#"@component("x-counter") class Counter { count = state(1); render() { return <button>{this.count}</button>; } }"#,
599        );
600        let graph = build_resume_boundary_graph(&model);
601        let liveness = build_resume_liveness_plan(&model);
602        let registry = build_resume_schema_registry(&model);
603        assert_eq!(registry.schemas.len(), graph.boundaries.len());
604        assert_eq!(
605            registry
606                .schemas
607                .iter()
608                .map(|schema| &schema.boundary)
609                .collect::<Vec<_>>(),
610            graph
611                .boundaries
612                .iter()
613                .map(|boundary| &boundary.id)
614                .collect::<Vec<_>>()
615        );
616        let expected = capture_eligible_slots(&liveness)
617            .into_iter()
618            .map(|slot| slot.existing_slot.clone())
619            .collect::<BTreeSet<_>>();
620        let actual = registry.slot_index.keys().cloned().collect::<BTreeSet<_>>();
621        assert_eq!(actual, expected);
622        assert!(registry.blocks.is_empty());
623        assert_eq!(validate_resume_schema_registry(&model, &registry), Ok(()));
624    }
625
626    #[test]
627    fn freezes_form_runtime_slot_codecs_without_runtime_guessing() {
628        let model = model(
629            r#"@component("x-profile") class Profile {
630  @form() profile!: Form;
631  @field(this.profile) name = "";
632  render() { return <input field={this.name} />; }
633}"#,
634        );
635        let registry = build_resume_schema_registry(&model);
636        let codecs = registry
637            .schemas
638            .iter()
639            .flat_map(|schema| &schema.slots)
640            .map(|slot| (&slot.existing_slot, &slot.codec))
641            .collect::<BTreeMap<_, _>>();
642        assert!(codecs.iter().any(|(slot, codec)| matches!(
643            (slot, codec),
644            (
645                ResumeExistingSlot::FormFieldValidation(_),
646                ResumeValueCodec::ArrayCodec(element)
647            ) if **element == ResumeValueCodec::StringCodec
648        )));
649        assert!(codecs.iter().any(|(slot, codec)| matches!(
650            (slot, codec),
651            (
652                ResumeExistingSlot::FormValidationAggregate(_),
653                ResumeValueCodec::BooleanCodec
654            )
655        )));
656        assert!(codecs.iter().any(|(slot, codec)| matches!(
657            (slot, codec),
658            (
659                ResumeExistingSlot::FormSubmission(_),
660                ResumeValueCodec::StringCodec
661            )
662        )));
663    }
664
665    #[test]
666    fn integrity_rejects_identity_and_ordering_drift() {
667        let model = model(
668            r#"@component("x-counter") class Counter { count = state(1); render() { return <button>{this.count}</button>; } }"#,
669        );
670        let mut registry = build_resume_schema_registry(&model);
671        registry.schemas.reverse();
672        let diagnostics = validate_resume_schema_registry(&model, &registry).expect_err("drift");
673        assert!(diagnostics.iter().any(|diagnostic| {
674            diagnostic.code == ResumeSchemaIntegrityCode::OrderingOrIndexDrift
675        }));
676    }
677
678    #[test]
679    fn schema_registry_is_deterministic_under_source_reversal() {
680        let first = presolve_parser::parse_file(
681            "src/A.tsx",
682            r#"@component("x-a") @route("/a") class A { value = state({ b: "two", a: 1 }); render() { return <button>{this.value}</button>; } }"#,
683        );
684        let second = presolve_parser::parse_file(
685            "src/B.tsx",
686            r#"@component("x-b") @route("/b") class B { enabled = state(true); render() { return <button>{this.enabled}</button>; } }"#,
687        );
688        let forward = crate::build_application_semantic_model_for_unit(
689            &crate::CompilationUnit::from_parsed_files(vec![first.clone(), second.clone()]),
690        );
691        let reverse = crate::build_application_semantic_model_for_unit(
692            &crate::CompilationUnit::from_parsed_files(vec![second, first]),
693        );
694        assert_eq!(
695            build_resume_schema_registry(&forward),
696            build_resume_schema_registry(&reverse)
697        );
698    }
699
700    #[test]
701    fn schema_order_keeps_nested_boundaries_parent_before_child() {
702        let model = model(
703            r#"@component("x-child") class Child { value = state(1); render() { return <span>{this.value}</span>; } }
704@component("x-parent") @route("/") class Parent { render() { return <Child />; } }"#,
705        );
706        let graph = build_resume_boundary_graph(&model);
707        let registry = build_resume_schema_registry(&model);
708        assert_eq!(
709            registry
710                .schemas
711                .iter()
712                .map(|schema| &schema.boundary)
713                .collect::<Vec<_>>(),
714            graph
715                .boundaries
716                .iter()
717                .map(|boundary| &boundary.id)
718                .collect::<Vec<_>>()
719        );
720    }
721
722    #[test]
723    fn reserves_the_complete_j6_integrity_range() {
724        assert_eq!(
725            [
726                ResumeSchemaIntegrityCode::MalformedSemanticType,
727                ResumeSchemaIntegrityCode::DuplicateProperty,
728                ResumeSchemaIntegrityCode::UnsupportedValue,
729                ResumeSchemaIntegrityCode::MissingSlot,
730                ResumeSchemaIntegrityCode::IdentityCollision,
731                ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
732            ]
733            .map(ResumeSchemaIntegrityCode::code),
734            [
735                "PSASM1349",
736                "PSASM1350",
737                "PSASM1351",
738                "PSASM1352",
739                "PSASM1353",
740                "PSASM1354",
741            ]
742        );
743    }
744}