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