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 activation in model.resource_activations.values() {
318            let Some(declaration) = model.resource_declarations.get(&activation.declaration) else {
319                continue;
320            };
321            let snapshot_policy = model
322                .resource_endpoint_resolutions
323                .iter()
324                .any(|resolution| {
325                    resolution.owner_component == declaration.owner_component
326                        && resolution.field == declaration.name
327                        && matches!(
328                            resolution.outcome,
329                            crate::ResourceEndpointResolutionOutcome::Resolved(ref endpoint)
330                                if matches!(
331                                    endpoint.endpoint.resume,
332                                    crate::SemanticPackageResourceResumePolicy::Snapshot
333                                )
334                        )
335                });
336            if !snapshot_policy {
337                continue;
338            }
339            types.insert(
340                ResumeExistingSlot::ResourceState(activation.id.state_slot()),
341                SemanticType::Object(crate::ObjectType {
342                    properties: BTreeMap::from([
343                        ("generation".to_string(), SemanticType::Number),
344                        ("state".to_string(), SemanticType::String),
345                    ]),
346                }),
347            );
348            types.insert(
349                ResumeExistingSlot::ResourceData(activation.id.data_slot()),
350                SemanticType::Union(vec![SemanticType::Null, declaration.data_type.clone()]),
351            );
352            types.insert(
353                ResumeExistingSlot::ResourceError(activation.id.error_slot()),
354                SemanticType::Union(vec![SemanticType::Null, declaration.error_type.clone()]),
355            );
356        }
357        for record in build_computed_instance_slot_registry(model, &ir).records {
358            if let Some(semantic_type) = model.semantic_type_of(&record.computed_id) {
359                types.insert(
360                    ResumeExistingSlot::ComputedCache(record.cache_slot_id),
361                    semantic_type.clone(),
362                );
363            }
364            types.insert(
365                ResumeExistingSlot::ComputedDirty(record.dirty_slot_id),
366                SemanticType::Boolean,
367            );
368        }
369        let runtime_components =
370            build_runtime_component_registry(model, &model.component_ir_optimization);
371        for binding in runtime_components.instance_context_bindings {
372            if let Some(semantic_type) =
373                model.semantic_type_of(binding.selected_source.context.as_semantic_id())
374            {
375                types.insert(
376                    ResumeExistingSlot::Context(binding.runtime_slot),
377                    semantic_type.clone(),
378                );
379            }
380        }
381        for instance in model.optimized_form_ir.optimized.instances.values() {
382            for (field_id, slot) in &instance.storage.value {
383                if let Some(field) = model.form_fields.get(field_id) {
384                    types.insert(
385                        ResumeExistingSlot::FormFieldValue(slot.clone()),
386                        field.semantic_type.clone(),
387                    );
388                }
389            }
390            for slot in instance.storage.dirty.values() {
391                types.insert(
392                    ResumeExistingSlot::FormFieldDirty(slot.clone()),
393                    SemanticType::Boolean,
394                );
395            }
396            for slot in instance.storage.touched.values() {
397                types.insert(
398                    ResumeExistingSlot::FormFieldTouched(slot.clone()),
399                    SemanticType::Boolean,
400                );
401            }
402            for slot in instance.storage.validation.values() {
403                types.insert(
404                    ResumeExistingSlot::FormFieldValidation(slot.clone()),
405                    SemanticType::Array(Box::new(SemanticType::String)),
406                );
407            }
408            types.insert(
409                ResumeExistingSlot::FormValidationAggregate(instance.storage.aggregate.clone()),
410                SemanticType::Boolean,
411            );
412            types.insert(
413                ResumeExistingSlot::FormSubmission(instance.storage.submission.clone()),
414                SemanticType::String,
415            );
416        }
417        Self { types }
418    }
419
420    fn semantic_type(&self, slot: &ResumeExistingSlot) -> Option<SemanticType> {
421        self.types.get(slot).cloned()
422    }
423}
424
425/// # Errors
426///
427/// Returns exact J6 integrity diagnostics for malformed codecs, missing schema
428/// reciprocity, identity collisions, or canonical ordering/index drift.
429pub fn validate_resume_schema_registry(
430    model: &ApplicationSemanticModel,
431    registry: &ResumeSchemaRegistry,
432) -> Result<(), Vec<ResumeSchemaIntegrityDiagnostic>> {
433    let mut diagnostics = Vec::new();
434    let boundaries = build_resume_boundary_graph(model);
435    let liveness = build_resume_liveness_plan(model);
436    let expected_boundary_order = boundaries
437        .boundaries
438        .iter()
439        .map(|boundary| boundary.id.clone())
440        .collect::<Vec<_>>();
441    let actual_boundary_order = registry
442        .schemas
443        .iter()
444        .map(|schema| schema.boundary.clone())
445        .collect::<Vec<_>>();
446    if expected_boundary_order != actual_boundary_order {
447        diagnostics.push(diagnostic(
448            ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
449            None,
450            None,
451            "resume schemas do not preserve canonical parent-before-child boundary order",
452        ));
453    }
454
455    let mut schema_ids = BTreeSet::new();
456    let mut resume_slot_ids = BTreeSet::new();
457    let mut existing_slots = BTreeSet::new();
458    for schema in &registry.schemas {
459        if schema.id != ResumeSchemaId::for_boundary(&schema.boundary)
460            || !schema_ids.insert(schema.id.clone())
461        {
462            diagnostics.push(diagnostic(
463                ResumeSchemaIntegrityCode::IdentityCollision,
464                Some(schema.boundary.clone()),
465                None,
466                "resume schema identity is not unique and boundary-derived",
467            ));
468        }
469        for slot in &schema.slots {
470            if slot.resume_slot_id != slot.existing_slot.resume_slot_id()
471                || !resume_slot_ids.insert(slot.resume_slot_id.clone())
472                || !existing_slots.insert(slot.existing_slot.clone())
473            {
474                diagnostics.push(diagnostic(
475                    ResumeSchemaIntegrityCode::IdentityCollision,
476                    Some(schema.boundary.clone()),
477                    Some(slot.existing_slot.clone()),
478                    "resume slot identity is not unique and storage-derived",
479                ));
480            }
481            validate_codec(
482                &slot.codec,
483                &schema.boundary,
484                &slot.existing_slot,
485                &mut diagnostics,
486            );
487        }
488    }
489
490    let expected_slots = capture_eligible_slots(&liveness)
491        .into_iter()
492        .map(|slot| slot.existing_slot.clone())
493        .collect::<BTreeSet<_>>();
494    let blocked_slots = registry
495        .blocks
496        .iter()
497        .map(|block| block.slot.clone())
498        .collect::<BTreeSet<_>>();
499    if !expected_slots
500        .iter()
501        .all(|slot| existing_slots.contains(slot) || blocked_slots.contains(slot))
502    {
503        diagnostics.push(diagnostic(
504            ResumeSchemaIntegrityCode::MissingSlot,
505            None,
506            None,
507            "capture-eligible liveness slot lacks a schema or explicit schema block",
508        ));
509    }
510    if registry.version != RESUME_SCHEMA_REGISTRY_VERSION
511        || registry != &build_resume_schema_registry(model)
512    {
513        diagnostics.push(diagnostic(
514            ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
515            None,
516            None,
517            "resume schema registry drifted from canonical ordering or indexes",
518        ));
519    }
520
521    if diagnostics.is_empty() {
522        Ok(())
523    } else {
524        Err(diagnostics)
525    }
526}
527
528fn validate_codec(
529    codec: &ResumeValueCodec,
530    boundary: &ResumeBoundaryId,
531    slot: &ResumeExistingSlot,
532    diagnostics: &mut Vec<ResumeSchemaIntegrityDiagnostic>,
533) {
534    match codec {
535        ResumeValueCodec::ArrayCodec(element) | ResumeValueCodec::NullableCodec(element) => {
536            validate_codec(element, boundary, slot, diagnostics);
537        }
538        ResumeValueCodec::ObjectCodec(properties) => {
539            let names = properties
540                .iter()
541                .map(|property| property.name.as_str())
542                .collect::<Vec<_>>();
543            if names.windows(2).any(|pair| pair[0] == pair[1]) {
544                diagnostics.push(diagnostic(
545                    ResumeSchemaIntegrityCode::DuplicateProperty,
546                    Some(boundary.clone()),
547                    Some(slot.clone()),
548                    "resume object codec contains a duplicate property",
549                ));
550            }
551            if names.windows(2).any(|pair| pair[0] > pair[1]) {
552                diagnostics.push(diagnostic(
553                    ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
554                    Some(boundary.clone()),
555                    Some(slot.clone()),
556                    "resume object codec properties are not in canonical order",
557                ));
558            }
559            for property in properties {
560                validate_codec(&property.codec, boundary, slot, diagnostics);
561            }
562        }
563        ResumeValueCodec::NullCodec
564        | ResumeValueCodec::BooleanCodec
565        | ResumeValueCodec::NumberCodec
566        | ResumeValueCodec::StringCodec => {}
567    }
568}
569
570fn diagnostic(
571    code: ResumeSchemaIntegrityCode,
572    boundary: Option<ResumeBoundaryId>,
573    slot: Option<ResumeExistingSlot>,
574    message: &str,
575) -> ResumeSchemaIntegrityDiagnostic {
576    ResumeSchemaIntegrityDiagnostic {
577        code,
578        boundary,
579        slot,
580        message: message.to_string(),
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use crate::{
588        build_application_semantic_model_for_unit_with_packages, parse_semantic_package_contract,
589        CompilationUnit, ObjectType, SemanticPackageResolutionTable,
590    };
591
592    fn model(source: &str) -> ApplicationSemanticModel {
593        crate::build_application_semantic_model(&presolve_parser::parse_file(
594            "src/ResumeSchema.tsx",
595            source,
596        ))
597    }
598
599    fn resource_model(resume_policy: &str) -> ApplicationSemanticModel {
600        let unit = CompilationUnit::parse_sources([(
601            "src/Profile.tsx",
602            r#"
603import { loadProfile } from "profile-service";
604@component("x-profile") @route("/") class Profile extends Component {
605  @resource("loadProfile") profile!: Resource<string, string>;
606  @computed() get profileName(): string | null { return this.profile.data; }
607  render() { return <main>{this.profileName}</main>; }
608}
609"#,
610        )]);
611        let contract = parse_semantic_package_contract(&format!(
612            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}"}}}}}}}}"#
613        ))
614        .expect("resource contract");
615        let mut packages = SemanticPackageResolutionTable::default();
616        packages.insert("profile-service".into(), contract).unwrap();
617        build_application_semantic_model_for_unit_with_packages(&unit, &packages)
618    }
619
620    #[test]
621    fn derives_closed_codecs_with_canonical_object_order_and_explicit_nullable() {
622        let object = SemanticType::Object(ObjectType {
623            properties: BTreeMap::from([
624                ("b".to_string(), SemanticType::String),
625                ("a".to_string(), SemanticType::Number),
626            ]),
627        });
628        assert_eq!(
629            resume_value_codec(&object),
630            Ok(ResumeValueCodec::ObjectCodec(vec![
631                ResumeObjectPropertyCodec {
632                    name: "a".to_string(),
633                    codec: ResumeValueCodec::NumberCodec,
634                },
635                ResumeObjectPropertyCodec {
636                    name: "b".to_string(),
637                    codec: ResumeValueCodec::StringCodec,
638                },
639            ]))
640        );
641        assert_eq!(
642            resume_value_codec(&SemanticType::Union(vec![
643                SemanticType::Null,
644                SemanticType::String,
645            ])),
646            Ok(ResumeValueCodec::NullableCodec(Box::new(
647                ResumeValueCodec::StringCodec
648            )))
649        );
650        assert_eq!(
651            resume_value_codec(&SemanticType::Union(vec![
652                SemanticType::String,
653                SemanticType::Number,
654            ])),
655            Err(ResumeSchemaBlockReason::UnsupportedValue)
656        );
657    }
658
659    #[test]
660    fn creates_one_schema_per_boundary_with_exact_slot_reciprocity() {
661        let model = model(
662            r#"@component("x-counter") class Counter { count = state(1); render() { return <button>{this.count}</button>; } }"#,
663        );
664        let graph = build_resume_boundary_graph(&model);
665        let liveness = build_resume_liveness_plan(&model);
666        let registry = build_resume_schema_registry(&model);
667        assert_eq!(registry.schemas.len(), graph.boundaries.len());
668        assert_eq!(
669            registry
670                .schemas
671                .iter()
672                .map(|schema| &schema.boundary)
673                .collect::<Vec<_>>(),
674            graph
675                .boundaries
676                .iter()
677                .map(|boundary| &boundary.id)
678                .collect::<Vec<_>>()
679        );
680        let expected = capture_eligible_slots(&liveness)
681            .into_iter()
682            .map(|slot| slot.existing_slot.clone())
683            .collect::<BTreeSet<_>>();
684        let actual = registry.slot_index.keys().cloned().collect::<BTreeSet<_>>();
685        assert_eq!(actual, expected);
686        assert!(registry.blocks.is_empty());
687        assert_eq!(validate_resume_schema_registry(&model, &registry), Ok(()));
688    }
689
690    #[test]
691    fn freezes_form_runtime_slot_codecs_without_runtime_guessing() {
692        let model = model(
693            r#"@component("x-profile") class Profile {
694  @form() profile!: Form;
695  @field(this.profile) name = "";
696  render() { return <input field={this.name} />; }
697}"#,
698        );
699        let registry = build_resume_schema_registry(&model);
700        let codecs = registry
701            .schemas
702            .iter()
703            .flat_map(|schema| &schema.slots)
704            .map(|slot| (&slot.existing_slot, &slot.codec))
705            .collect::<BTreeMap<_, _>>();
706        assert!(codecs.iter().any(|(slot, codec)| matches!(
707            (slot, codec),
708            (
709                ResumeExistingSlot::FormFieldValidation(_),
710                ResumeValueCodec::ArrayCodec(element)
711            ) if **element == ResumeValueCodec::StringCodec
712        )));
713        assert!(codecs.iter().any(|(slot, codec)| matches!(
714            (slot, codec),
715            (
716                ResumeExistingSlot::FormValidationAggregate(_),
717                ResumeValueCodec::BooleanCodec
718            )
719        )));
720        assert!(codecs.iter().any(|(slot, codec)| matches!(
721            (slot, codec),
722            (
723                ResumeExistingSlot::FormSubmission(_),
724                ResumeValueCodec::StringCodec
725            )
726        )));
727    }
728
729    #[test]
730    fn snapshot_resources_publish_instance_qualified_terminal_slots_before_computed_recompute() {
731        let model = resource_model("snapshot");
732        let liveness = build_resume_liveness_plan(&model);
733        let resource_slots =
734            liveness.slots_for_reason(crate::ResumeRetentionReason::ResourceSnapshotValue);
735        assert_eq!(resource_slots.len(), 3);
736        assert!(resource_slots.iter().all(|slot| matches!(
737            slot.existing_slot,
738            ResumeExistingSlot::ResourceState(_)
739                | ResumeExistingSlot::ResourceData(_)
740                | ResumeExistingSlot::ResourceError(_)
741        )));
742        let computed = liveness
743            .recomputable
744            .iter()
745            .find(|record| {
746                matches!(
747                    record.slot.existing_slot,
748                    ResumeExistingSlot::ComputedCache(_)
749                )
750            })
751            .expect("profile computed cache");
752        assert!(matches!(
753            computed.slot.direct_dependencies.as_slice(),
754            [ResumeExistingSlot::ResourceData(_)]
755        ));
756
757        let registry = build_resume_schema_registry(&model);
758        let codecs = registry
759            .schemas
760            .iter()
761            .flat_map(|schema| &schema.slots)
762            .map(|slot| (&slot.existing_slot, &slot.codec))
763            .collect::<BTreeMap<_, _>>();
764        assert!(codecs.iter().any(|(slot, codec)| matches!(
765            (slot, codec),
766            (
767                ResumeExistingSlot::ResourceState(_),
768                ResumeValueCodec::ObjectCodec(properties)
769            ) if properties == &vec![
770                ResumeObjectPropertyCodec {
771                    name: "generation".to_string(),
772                    codec: ResumeValueCodec::NumberCodec,
773                },
774                ResumeObjectPropertyCodec {
775                    name: "state".to_string(),
776                    codec: ResumeValueCodec::StringCodec,
777                },
778            ]
779        )));
780        assert_eq!(
781            codecs
782                .iter()
783                .filter(|(slot, codec)| matches!(
784                    (slot, codec),
785                    (ResumeExistingSlot::ResourceData(_), ResumeValueCodec::NullableCodec(inner))
786                        if **inner == ResumeValueCodec::StringCodec
787                ) || matches!(
788                    (slot, codec),
789                    (ResumeExistingSlot::ResourceError(_), ResumeValueCodec::NullableCodec(inner))
790                        if **inner == ResumeValueCodec::StringCodec
791                ))
792                .count(),
793            2
794        );
795        let resource_slot_ids = registry
796            .schemas
797            .iter()
798            .flat_map(|schema| &schema.slots)
799            .filter(|slot| {
800                matches!(
801                    slot.existing_slot,
802                    ResumeExistingSlot::ResourceState(_)
803                        | ResumeExistingSlot::ResourceData(_)
804                        | ResumeExistingSlot::ResourceError(_)
805                )
806            })
807            .map(|slot| slot.resume_slot_id.clone())
808            .collect::<BTreeSet<_>>();
809        let capture = crate::build_resume_capture_plan(&model);
810        let captured = capture
811            .programs
812            .iter()
813            .flat_map(|program| &program.instructions)
814            .filter_map(|instruction| match instruction {
815                crate::ResumeCaptureInstruction::ReadSlot { slot_id } => Some(slot_id.clone()),
816                _ => None,
817            })
818            .collect::<BTreeSet<_>>();
819        assert!(resource_slot_ids.is_subset(&captured));
820        let restore = crate::build_resume_restore_plan(&model);
821        assert_eq!(
822            restore
823                .programs
824                .iter()
825                .flat_map(|program| &program.slot_assignments)
826                .filter(|assignment| matches!(
827                    assignment.slot,
828                    ResumeExistingSlot::ResourceState(_)
829                        | ResumeExistingSlot::ResourceData(_)
830                        | ResumeExistingSlot::ResourceError(_)
831                ))
832                .map(|assignment| assignment.phase)
833                .collect::<BTreeSet<_>>(),
834            BTreeSet::from([crate::ResumeRestorePhase::R3RestoreMutableStateAndResources])
835        );
836        let manifest = crate::build_resume_manifest(&model);
837        assert_eq!(
838            manifest
839                .slot_schemas
840                .iter()
841                .filter(|slot| matches!(
842                    slot.retention_reason,
843                    crate::resume_manifest::ResumeManifestRetentionReason::SerializableResourceValue
844                ))
845                .count(),
846            3
847        );
848        assert!(validate_resume_schema_registry(&model, &registry).is_ok());
849    }
850
851    #[test]
852    fn reload_resources_do_not_publish_snapshot_slots() {
853        let model = resource_model("reload");
854        let liveness = build_resume_liveness_plan(&model);
855        assert!(liveness
856            .slots_for_reason(crate::ResumeRetentionReason::ResourceSnapshotValue)
857            .is_empty());
858        assert!(build_resume_schema_registry(&model)
859            .schemas
860            .iter()
861            .flat_map(|schema| &schema.slots)
862            .all(|slot| !matches!(
863                slot.existing_slot,
864                ResumeExistingSlot::ResourceState(_)
865                    | ResumeExistingSlot::ResourceData(_)
866                    | ResumeExistingSlot::ResourceError(_)
867            )));
868    }
869
870    #[test]
871    fn integrity_rejects_identity_and_ordering_drift() {
872        let model = model(
873            r#"@component("x-counter") class Counter { count = state(1); render() { return <button>{this.count}</button>; } }"#,
874        );
875        let mut registry = build_resume_schema_registry(&model);
876        registry.schemas.reverse();
877        let diagnostics = validate_resume_schema_registry(&model, &registry).expect_err("drift");
878        assert!(diagnostics.iter().any(|diagnostic| {
879            diagnostic.code == ResumeSchemaIntegrityCode::OrderingOrIndexDrift
880        }));
881    }
882
883    #[test]
884    fn schema_registry_is_deterministic_under_source_reversal() {
885        let first = presolve_parser::parse_file(
886            "src/A.tsx",
887            r#"@component("x-a") @route("/a") class A { value = state({ b: "two", a: 1 }); render() { return <button>{this.value}</button>; } }"#,
888        );
889        let second = presolve_parser::parse_file(
890            "src/B.tsx",
891            r#"@component("x-b") @route("/b") class B { enabled = state(true); render() { return <button>{this.enabled}</button>; } }"#,
892        );
893        let forward = crate::build_application_semantic_model_for_unit(
894            &crate::CompilationUnit::from_parsed_files(vec![first.clone(), second.clone()]),
895        );
896        let reverse = crate::build_application_semantic_model_for_unit(
897            &crate::CompilationUnit::from_parsed_files(vec![second, first]),
898        );
899        assert_eq!(
900            build_resume_schema_registry(&forward),
901            build_resume_schema_registry(&reverse)
902        );
903    }
904
905    #[test]
906    fn schema_order_keeps_nested_boundaries_parent_before_child() {
907        let model = model(
908            r#"@component("x-child") class Child { value = state(1); render() { return <span>{this.value}</span>; } }
909@component("x-parent") @route("/") class Parent { render() { return <Child />; } }"#,
910        );
911        let graph = build_resume_boundary_graph(&model);
912        let registry = build_resume_schema_registry(&model);
913        assert_eq!(
914            registry
915                .schemas
916                .iter()
917                .map(|schema| &schema.boundary)
918                .collect::<Vec<_>>(),
919            graph
920                .boundaries
921                .iter()
922                .map(|boundary| &boundary.id)
923                .collect::<Vec<_>>()
924        );
925    }
926
927    #[test]
928    fn reserves_the_complete_j6_integrity_range() {
929        assert_eq!(
930            [
931                ResumeSchemaIntegrityCode::MalformedSemanticType,
932                ResumeSchemaIntegrityCode::DuplicateProperty,
933                ResumeSchemaIntegrityCode::UnsupportedValue,
934                ResumeSchemaIntegrityCode::MissingSlot,
935                ResumeSchemaIntegrityCode::IdentityCollision,
936                ResumeSchemaIntegrityCode::OrderingOrIndexDrift,
937            ]
938            .map(ResumeSchemaIntegrityCode::code),
939            [
940                "PSASM1349",
941                "PSASM1350",
942                "PSASM1351",
943                "PSASM1352",
944                "PSASM1353",
945                "PSASM1354",
946            ]
947        );
948    }
949}