1use 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
126pub 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
425pub 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 ®istry.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, ®istry), 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, ®istry).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, ®istry).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}