1use crate::{
7 db::{
8 Db,
9 codec::{
10 finalize_hash_sha256, new_hash_sha256_prefixed, write_hash_len_u32, write_hash_str_u32,
11 write_hash_tag_u8, write_hash_u64,
12 },
13 commit::{
14 AcceptedSchemaPublication, DatabaseControlOp, database_incarnation_id,
15 ensure_recovery_admitted, publish_accepted_schema_candidates_with_application_record,
16 publish_accepted_schema_candidates_with_database_control,
17 publish_generated_row_local_abort_with_application_record,
18 },
19 data::DataStore,
20 index::{IndexState, IndexStore},
21 integrity::DatabaseIncarnationId,
22 registry::{
23 StoreAllocationIdentity, StoreAllocationIdentityCapability, StoreCommitParticipation,
24 StoreDurability, StoreHandle, StoreRecoveryCapability, StoreRelationSourceCapability,
25 StoreRelationTargetCapability, StoreRuntimeStorageMode, StoreSchemaMetadataCapability,
26 },
27 relation::prove_empty_reverse_relation_domain,
28 schema::ensure_schema_migration_ready_for_ordinary_operations,
29 schema::{
30 AcceptedSchemaRevision, AcceptedSchemaRevisionBundle, CandidateSchemaRevision,
31 ConstraintActivationKind, ConstraintActivationState, ConstraintId, ConstraintOrigin,
32 ConstraintValidationPhase, ConstraintValidationProgress, ExistingProposalStore,
33 MAX_IDENTITY_STATE_RECORDS_PER_DATABASE, ProposalStoreTarget, SchemaApplicationRecord,
34 SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob, SchemaChangeJobId,
35 SchemaChangeOutcome, SchemaChangeProgress, SchemaChangeProgressStatus,
36 SchemaChangeReceipt, SchemaChangeValidationPhase, StagedUserIndexDomainError,
37 UnpublishedRowLocalValidation, advance_accepted_row_local_constraint_activation,
38 constraint_validation_finding_output, derive_schema_change_job_id,
39 load_schema_application_record_read_only, lower_existing_schema_proposal,
40 lower_initial_schema_proposal, prove_empty_user_index_domain,
41 validate_unpublished_row_local_candidate_bounded, with_schema_application_store,
42 },
43 },
44 error::InternalError,
45 traits::CanisterKind,
46 types::EntityTag,
47};
48use candid::CandidType;
49use icydb_schema::{
50 ExpectedAcceptedHead, ExpectedSchemaFingerprint, SchemaProposal, SchemaProposalDigest,
51 SchemaSubmissionKey, TargetDatabaseIdentity, TargetStoreIdentity,
52};
53use serde::Deserialize;
54use sha2::Digest;
55#[cfg(feature = "migration")]
56use std::collections::BTreeMap;
57
58#[cfg(feature = "migration")]
59use crate::db::schema::{
60 PersistedSchemaMigrationEntity, PersistedSchemaMigrationFindingKind,
61 PersistedSchemaMigrationIndex, PersistedSchemaMigrationPhase,
62 PersistedSchemaMigrationTransition, SchemaMigrationCommand, SchemaMigrationEntityTransition,
63 SchemaMigrationFinding, SchemaMigrationFindingKind, SchemaMigrationPhase,
64 SchemaMigrationReceipt, SchemaMigrationRecord, SchemaMigrationRecordOp,
65 SchemaMigrationStatusPage, SchemaMigrationStatusRequest,
66 live_schema_checkpoint::{load_entity_source_lineage_catalog, load_schema_migration_record},
67 migration_execution::{
68 cleanup_migration_staging_page, final_validate_migration_page,
69 migration_derived_domain_count, publish_migration_rewrite_page, rewrite_migration_page,
70 },
71 migration_lineage::{
72 AcceptedEntitySourceLineage, AcceptedEntitySourceLineageCatalog,
73 AcceptedEntitySourceLineageState, EntitySourceLineageCatalogOp,
74 },
75 migration_planner::{
76 PlannedEntitySourceLineage, SchemaMigrationPlanningError, plan_entity_source_adoption,
77 plan_initial_entity_source_lineage, plan_schema_migration,
78 },
79 migration_validation::{stage_migration_index_entries, validate_migration_page},
80};
81
82#[cfg(feature = "migration")]
83use icydb_diagnostic_code::SchemaMigrationCode;
84#[cfg(feature = "migration")]
85use icydb_schema::{EntitySourceKey, SchemaMigrationPlanDigest};
86
87const DATABASE_TARGET_FINGERPRINT_PROFILE: &[u8] = b"icydb.schema-target.database.v1";
88const STORE_TARGET_FINGERPRINT_PROFILE: &[u8] = b"icydb.schema-target.store.v1";
89const ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE: &[u8] = b"icydb.accepted-schema.database-head.v1";
90#[cfg(feature = "migration")]
91const SCHEMA_MIGRATION_SUBMISSION_PROFILE: &[u8] = b"icydb.schema-migration.submission.v1";
92
93#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
101pub struct SchemaApplicationStore {
102 path: String,
103 identity: TargetStoreIdentity,
104}
105
106impl SchemaApplicationStore {
107 #[must_use]
109 pub const fn path(&self) -> &str {
110 self.path.as_str()
111 }
112
113 #[must_use]
115 pub const fn identity(&self) -> TargetStoreIdentity {
116 self.identity
117 }
118}
119
120#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
129pub struct SchemaApplicationTarget {
130 database_identity: TargetDatabaseIdentity,
131 accepted_head: ExpectedAcceptedHead,
132 stores: Vec<SchemaApplicationStore>,
133}
134
135impl SchemaApplicationTarget {
136 #[must_use]
138 pub const fn database_identity(&self) -> TargetDatabaseIdentity {
139 self.database_identity
140 }
141
142 #[must_use]
144 pub const fn accepted_head(&self) -> &ExpectedAcceptedHead {
145 &self.accepted_head
146 }
147
148 #[must_use]
150 pub const fn stores(&self) -> &[SchemaApplicationStore] {
151 self.stores.as_slice()
152 }
153}
154
155#[derive(Clone, Copy)]
163struct StoreApplicationAuthority {
164 path: &'static str,
165 handle: StoreHandle,
166}
167
168struct PendingApplicationAbort {
170 authority: StoreApplicationAuthority,
171 current: AcceptedSchemaRevisionBundle,
172 entity_tag: EntityTag,
173 constraint_id: ConstraintId,
174 remove_validation_job: bool,
175}
176
177#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185struct AcceptedStoreHead {
186 revision: u64,
187 fingerprint: [u8; 32],
188}
189
190#[derive(Clone)]
192struct DirectGeneratedRowLocalProof {
193 candidate_index: usize,
194 store: StoreHandle,
195 store_path: &'static str,
196 entity_tag: crate::types::EntityTag,
197 entity_path: String,
198 constraint_id: ConstraintId,
199 historical_rows: u64,
200}
201
202#[derive(Clone)]
204struct PendingGeneratedRowLocalConstraint {
205 proof: DirectGeneratedRowLocalProof,
206}
207
208struct LoweredApplication {
210 current_bundles: Vec<Option<AcceptedSchemaRevisionBundle>>,
211 candidates: Vec<CandidateSchemaRevision>,
212 pending: Option<PendingGeneratedRowLocalConstraint>,
213}
214
215pub(in crate::db) fn schema_application_target<C: CanisterKind>(
217 db: &Db<C>,
218) -> Result<SchemaApplicationTarget, InternalError> {
219 ensure_recovery_admitted(db)?;
220 let incarnation = database_incarnation_id()?;
221 let mut stores = db.with_store_registry(|registry| {
222 registry
223 .iter()
224 .map(|(path, handle)| StoreApplicationAuthority { path, handle })
225 .collect::<Vec<_>>()
226 });
227 stores.sort_unstable_by(|left, right| left.path.cmp(right.path));
228
229 let database_identity = derive_database_identity(incarnation.to_bytes(), stores.as_slice());
230 let mut accepted_heads = Vec::with_capacity(stores.len());
231 let mut application_stores = Vec::with_capacity(stores.len());
232 for store in &stores {
233 let root = store
234 .handle
235 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
236 .map(|selection| AcceptedStoreHead {
237 revision: selection.root().revision().get(),
238 fingerprint: selection.root().fingerprint().as_bytes(),
239 });
240 accepted_heads.push((store.path, root));
241 application_stores.push(SchemaApplicationStore {
242 path: store.path.to_string(),
243 identity: derive_store_identity(database_identity, store),
244 });
245 }
246
247 Ok(SchemaApplicationTarget {
248 database_identity,
249 accepted_head: derive_accepted_head(accepted_heads.as_slice()),
250 stores: application_stores,
251 })
252}
253
254pub(in crate::db) fn schema_application_receipt<C: CanisterKind>(
257 db: &Db<C>,
258 database_identity: TargetDatabaseIdentity,
259 submission_key: &SchemaSubmissionKey,
260) -> Result<Option<SchemaChangeReceipt>, InternalError> {
261 ensure_recovery_admitted(db)?;
262 with_schema_application_store(|store| {
263 store
264 .load(database_identity, submission_key)
265 .map(|record| record.map(|record| record.receipt().clone()))
266 })
267}
268
269fn exact_schema_application_receipt(
270 proposal: &SchemaProposal,
271 proposal_digest: SchemaProposalDigest,
272) -> Result<Option<SchemaChangeReceipt>, InternalError> {
273 let Some(record) = with_schema_application_store(|store| {
274 store.load(proposal.target_database(), proposal.submission_key())
275 })?
276 else {
277 return Ok(None);
278 };
279 let receipt = record.receipt();
280 if !receipt.is_exact_submission(
281 proposal.target_database(),
282 proposal.submission_key(),
283 proposal_digest,
284 proposal.expected_head(),
285 ) {
286 return Err(InternalError::schema_application_conflict());
287 }
288 Ok(Some(receipt.clone()))
289}
290
291pub(in crate::db) fn continue_schema_application<C: CanisterKind>(
294 db: &Db<C>,
295 job_id: SchemaChangeJobId,
296 acknowledged_receipt: Option<u64>,
297) -> Result<SchemaChangeProgress, InternalError> {
298 ensure_recovery_admitted(db)?;
299 ensure_schema_migration_ready_for_ordinary_operations()?;
300 let record = with_schema_application_store(|store| store.load_job(job_id))?
301 .ok_or_else(InternalError::schema_application_conflict)?;
302 let target = schema_application_target(db)?;
303 if target.database_identity() != record.receipt().database_identity() {
304 return Err(InternalError::schema_application_conflict());
305 }
306 let candidate_head = match record.receipt().outcome() {
307 SchemaChangeOutcome::Pending {
308 job,
309 candidate_head,
310 } if job.id() == job_id => candidate_head,
311 SchemaChangeOutcome::Applied { .. } => {
312 return Ok(SchemaChangeProgress::new(
313 record.receipt().clone(),
314 SchemaChangeProgressStatus::Applied,
315 ));
316 }
317 SchemaChangeOutcome::Aborted { .. } => {
318 return Ok(SchemaChangeProgress::new(
319 record.receipt().clone(),
320 SchemaChangeProgressStatus::Aborted,
321 ));
322 }
323 _ => return Err(InternalError::store_corruption()),
324 };
325 let [activation] = record.activations() else {
326 return Err(InternalError::store_corruption());
327 };
328 let authorities = application_authorities(db);
329 let authority = authorities
330 .iter()
331 .find(|authority| {
332 derive_store_identity(target.database_identity(), authority) == activation.store()
333 })
334 .ok_or_else(InternalError::store_corruption)?;
335 let entity_tag = EntityTag::new(activation.entity_tag());
336 let constraint_id = ConstraintId::new(activation.constraint_id())
337 .ok_or_else(InternalError::store_corruption)?;
338 let bundle = authority
339 .handle
340 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
341 .ok_or_else(InternalError::store_corruption)?;
342 if bundle.store_path() != authority.path {
343 return Err(InternalError::store_corruption());
344 }
345 let snapshot = bundle
346 .entity_snapshots()
347 .get(&entity_tag)
348 .ok_or_else(InternalError::store_corruption)?;
349
350 let accepted = snapshot
351 .constraint_catalog()
352 .constraints()
353 .iter()
354 .any(|constraint| {
355 constraint.id() == constraint_id
356 && constraint.origin() == ConstraintOrigin::Generated
357 && matches!(
358 constraint.kind(),
359 crate::db::schema::AcceptedConstraintKind::Check { .. }
360 | crate::db::schema::AcceptedConstraintKind::TargetedRule { .. }
361 )
362 });
363 let pending = snapshot.constraint_catalog().activation(constraint_id);
364 if accepted && pending.is_none() {
365 return finalize_schema_application(
366 db,
367 &record,
368 candidate_head,
369 SchemaChangeProgressStatus::Applied,
370 );
371 }
372 let pending = pending.ok_or_else(InternalError::store_corruption)?;
373 if pending.origin() != ConstraintOrigin::Generated
374 || !matches!(
375 pending.kind(),
376 ConstraintActivationKind::Check { .. } | ConstraintActivationKind::TargetedRule { .. }
377 )
378 {
379 return Err(InternalError::store_corruption());
380 }
381 let entity_path = snapshot.entity_path().to_string();
382 let progress = advance_accepted_row_local_constraint_activation(
383 authority.handle,
384 authority.path,
385 entity_tag,
386 entity_path.as_str(),
387 constraint_id,
388 acknowledged_receipt,
389 )?;
390 let status = schema_change_progress_status(snapshot, entity_tag, constraint_id, progress)?;
391 if status == SchemaChangeProgressStatus::Applied {
392 finalize_schema_application(db, &record, candidate_head, status)
393 } else {
394 Ok(SchemaChangeProgress::new(record.receipt().clone(), status))
395 }
396}
397
398pub(in crate::db) fn abort_schema_application<C: CanisterKind>(
404 db: &Db<C>,
405 job_id: SchemaChangeJobId,
406 acknowledged_receipt: Option<u64>,
407) -> Result<SchemaChangeProgress, InternalError> {
408 ensure_recovery_admitted(db)?;
409 ensure_schema_migration_ready_for_ordinary_operations()?;
410 let record = with_schema_application_store(|store| store.load_job(job_id))?
411 .ok_or_else(InternalError::schema_application_conflict)?;
412 let target = schema_application_target(db)?;
413 if target.database_identity() != record.receipt().database_identity() {
414 return Err(InternalError::schema_application_conflict());
415 }
416 match record.receipt().outcome() {
417 SchemaChangeOutcome::Applied { .. } => {
418 return Ok(SchemaChangeProgress::new(
419 record.receipt().clone(),
420 SchemaChangeProgressStatus::Applied,
421 ));
422 }
423 SchemaChangeOutcome::Aborted { .. } => {
424 return Ok(SchemaChangeProgress::new(
425 record.receipt().clone(),
426 SchemaChangeProgressStatus::Aborted,
427 ));
428 }
429 SchemaChangeOutcome::Pending { job, .. } if job.id() == job_id => {}
430 SchemaChangeOutcome::NoOp { .. } | SchemaChangeOutcome::Pending { .. } => {
431 return Err(InternalError::store_corruption());
432 }
433 }
434
435 let authorities = application_authorities(db);
436 let abort = prepare_pending_application_abort(
437 target.database_identity(),
438 &record,
439 authorities.as_slice(),
440 acknowledged_receipt,
441 )?;
442 let candidate = aborted_generated_row_local_candidate(
443 &abort.current,
444 abort.entity_tag,
445 abort.constraint_id,
446 )?;
447 let accepted_head =
448 accepted_head_after_candidates(authorities.as_slice(), std::slice::from_ref(&candidate))?;
449 let receipt = SchemaChangeReceipt::new(
450 record.receipt().database_identity(),
451 record.receipt().submission_key().clone(),
452 record.receipt().proposal_digest(),
453 record.receipt().prior_head().clone(),
454 SchemaChangeOutcome::Aborted { accepted_head },
455 )?;
456 let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
457 let operation = SchemaApplicationRecordOp::replace(&record, &terminal)?;
458 if abort.remove_validation_job {
459 publish_generated_row_local_abort_with_application_record(
460 abort.authority.path,
461 abort.authority.handle,
462 abort.current.revision(),
463 &candidate,
464 abort.entity_tag,
465 abort.constraint_id,
466 operation,
467 )?;
468 } else {
469 publish_accepted_schema_candidates_with_application_record(
470 vec![AcceptedSchemaPublication::new(
471 abort.authority.path,
472 abort.authority.handle,
473 abort.current.revision(),
474 &candidate,
475 )],
476 operation,
477 )?;
478 }
479 Ok(SchemaChangeProgress::new(
480 receipt,
481 SchemaChangeProgressStatus::Aborted,
482 ))
483}
484
485fn prepare_pending_application_abort(
486 database_identity: TargetDatabaseIdentity,
487 record: &SchemaApplicationRecord,
488 authorities: &[StoreApplicationAuthority],
489 acknowledged_receipt: Option<u64>,
490) -> Result<PendingApplicationAbort, InternalError> {
491 let [activation] = record.activations() else {
492 return Err(InternalError::store_corruption());
493 };
494 let authority = authorities
495 .iter()
496 .copied()
497 .find(|authority| derive_store_identity(database_identity, authority) == activation.store())
498 .ok_or_else(InternalError::store_corruption)?;
499 let entity_tag = EntityTag::new(activation.entity_tag());
500 let constraint_id = ConstraintId::new(activation.constraint_id())
501 .ok_or_else(InternalError::store_corruption)?;
502 let current = authority
503 .handle
504 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
505 .ok_or_else(InternalError::store_corruption)?;
506 if current.store_path() != authority.path {
507 return Err(InternalError::store_corruption());
508 }
509 let pending = current
510 .entity_snapshots()
511 .get(&entity_tag)
512 .and_then(|snapshot| snapshot.constraint_catalog().activation(constraint_id))
513 .filter(|pending| {
514 pending.origin() == ConstraintOrigin::Generated
515 && matches!(
516 pending.kind(),
517 ConstraintActivationKind::Check { .. }
518 | ConstraintActivationKind::TargetedRule { .. }
519 )
520 })
521 .ok_or_else(InternalError::store_corruption)?;
522 let remove_validation_job = pending_generated_row_local_job_retirement(
523 authority,
524 entity_tag,
525 constraint_id,
526 pending.state(),
527 acknowledged_receipt,
528 )?;
529 Ok(PendingApplicationAbort {
530 authority,
531 current,
532 entity_tag,
533 constraint_id,
534 remove_validation_job,
535 })
536}
537
538fn pending_generated_row_local_job_retirement(
539 authority: StoreApplicationAuthority,
540 entity_tag: EntityTag,
541 constraint_id: ConstraintId,
542 state: ConstraintActivationState,
543 acknowledged_receipt: Option<u64>,
544) -> Result<bool, InternalError> {
545 let job = authority
546 .handle
547 .with_schema(|store| store.constraint_validation_job(entity_tag, constraint_id))?;
548 match state {
549 ConstraintActivationState::EnforcingNewWrites => {
550 if acknowledged_receipt.is_some() || job.is_some() {
551 return Err(InternalError::schema_application_conflict());
552 }
553 Ok(false)
554 }
555 ConstraintActivationState::Validating => {
556 let mut job = job.ok_or_else(InternalError::store_corruption)?;
557 if !job.acknowledge_receipt(acknowledged_receipt) {
558 return Err(InternalError::schema_application_conflict());
559 }
560 Ok(true)
561 }
562 }
563}
564
565fn aborted_generated_row_local_candidate(
566 current: &AcceptedSchemaRevisionBundle,
567 entity_tag: EntityTag,
568 constraint_id: ConstraintId,
569) -> Result<CandidateSchemaRevision, InternalError> {
570 let snapshot = current
571 .entity_snapshots()
572 .get(&entity_tag)
573 .cloned()
574 .ok_or_else(InternalError::store_corruption)?;
575 let _activation = snapshot
576 .constraint_catalog()
577 .activation(constraint_id)
578 .filter(|activation| {
579 activation.origin() == ConstraintOrigin::Generated
580 && matches!(
581 activation.kind(),
582 ConstraintActivationKind::Check { .. }
583 | ConstraintActivationKind::TargetedRule { .. }
584 )
585 })
586 .ok_or_else(InternalError::store_corruption)?;
587 let catalog = snapshot
588 .constraint_catalog()
589 .clone()
590 .with_aborted_activation(constraint_id)
591 .map_err(|_| InternalError::store_invariant())?;
592 let accepted_identity_remains = catalog
593 .constraints()
594 .iter()
595 .any(|constraint| constraint.id() == constraint_id);
596 let mut snapshots = current.entity_snapshots().clone();
597 snapshots.insert(entity_tag, snapshot.with_constraint_catalog(catalog));
598 let mut source_bindings = current.source_bindings().clone();
599 if !accepted_identity_remains {
600 source_bindings.remove_constraint_identity(entity_tag, constraint_id)?;
601 }
602 let revision = current
603 .revision()
604 .checked_next()
605 .ok_or_else(InternalError::store_unsupported)?;
606 let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
607 revision,
608 current.store_path(),
609 current.enum_catalog().clone(),
610 current.composite_catalog().clone(),
611 source_bindings,
612 snapshots,
613 )?;
614 CandidateSchemaRevision::new(bundle)
615}
616
617pub(in crate::db) fn apply_schema<C: CanisterKind>(
620 db: &Db<C>,
621 proposal: &SchemaProposal,
622) -> Result<SchemaChangeReceipt, InternalError> {
623 ensure_recovery_admitted(db)?;
624 ensure_schema_migration_ready_for_ordinary_operations()?;
625 let proposal_digest = proposal
626 .digest()
627 .map_err(|_| InternalError::store_unsupported())?;
628 if let Some(receipt) = exact_schema_application_receipt(proposal, proposal_digest)? {
629 return Ok(receipt);
630 }
631
632 let target = schema_application_target(db)?;
633 if target.database_identity() != proposal.target_database()
634 || target.accepted_head() != proposal.expected_head()
635 {
636 return Err(InternalError::schema_application_conflict());
637 }
638
639 preflight_ordinary_source_application(db, proposal, &target)?;
640
641 let authorities = application_authorities(db);
642 let LoweredApplication {
643 current_bundles,
644 candidates,
645 pending,
646 } = lower_application_candidates(&target, proposal, authorities.as_slice())?;
647 validate_database_identity_state_capacity(
648 authorities.as_slice(),
649 candidates.as_slice(),
650 database_incarnation_id()?,
651 )?;
652 let accepted_head = if let Some(pending) = pending.as_ref() {
653 let final_candidates =
654 final_candidates_for_pending_row_local_constraint(&candidates, pending)?;
655 accepted_head_after_candidates(authorities.as_slice(), final_candidates.as_slice())?
656 } else if candidates.is_empty() {
657 target.accepted_head().clone()
658 } else {
659 accepted_head_after_candidates(authorities.as_slice(), candidates.as_slice())?
660 };
661 #[cfg(feature = "migration")]
662 let outcome_head = accepted_head.clone();
663 #[cfg(not(feature = "migration"))]
664 let outcome_head = accepted_head;
665 let outcome = if pending.is_some() {
666 let job_id = derive_schema_change_job_id(
667 target.database_identity(),
668 proposal.submission_key(),
669 proposal_digest,
670 target.accepted_head(),
671 )?;
672 SchemaChangeOutcome::Pending {
673 job: SchemaChangeJob::new(job_id),
674 candidate_head: outcome_head,
675 }
676 } else if candidates.is_empty() {
677 SchemaChangeOutcome::NoOp {
678 accepted_head: outcome_head,
679 }
680 } else {
681 SchemaChangeOutcome::Applied {
682 accepted_head: outcome_head,
683 }
684 };
685 let receipt = SchemaChangeReceipt::new(
686 target.database_identity(),
687 proposal.submission_key().clone(),
688 proposal_digest,
689 target.accepted_head().clone(),
690 outcome,
691 )?;
692 let activations = match pending {
693 Some(pending) => {
694 let authority = authorities
695 .iter()
696 .find(|authority| authority.path == pending.proof.store_path)
697 .ok_or_else(InternalError::store_invariant)?;
698 vec![SchemaChangeActivation::new(
699 derive_store_identity(target.database_identity(), authority),
700 pending.proof.entity_tag.value(),
701 pending.proof.constraint_id.get(),
702 )?]
703 }
704 None => Vec::new(),
705 };
706 let record = SchemaApplicationRecord::new(receipt.clone(), activations)?;
707 let operation = SchemaApplicationRecordOp::insert(&record)?;
708 #[cfg(feature = "migration")]
709 let database_control = attach_ordinary_lineage_publication(
710 proposal,
711 target.accepted_head(),
712 &accepted_head,
713 candidates.as_slice(),
714 operation,
715 )?;
716 #[cfg(not(feature = "migration"))]
717 let database_control = vec![DatabaseControlOp::SchemaApplication(operation)];
718 let publications =
719 application_publications(authorities.as_slice(), ¤t_bundles, &candidates)?;
720 publish_accepted_schema_candidates_with_database_control(publications, database_control)?;
721 Ok(receipt)
722}
723
724fn preflight_ordinary_source_application<C: CanisterKind>(
725 db: &Db<C>,
726 proposal: &SchemaProposal,
727 target: &SchemaApplicationTarget,
728) -> Result<(), InternalError> {
729 if proposal.migration().is_none()
730 || matches!(target.accepted_head(), ExpectedAcceptedHead::Empty)
731 {
732 return Ok(());
733 }
734 #[cfg(feature = "migration")]
735 if current_proposal_lineage_is_applied(db, proposal, target.accepted_head())? {
736 return Ok(());
737 }
738 #[cfg(feature = "migration")]
739 preflight_unpublished_schema_migration(target, proposal, db)?;
740 #[cfg(not(feature = "migration"))]
741 let _ = db;
742 Err(InternalError::store_unsupported())
743}
744
745#[cfg(feature = "migration")]
749pub(in crate::db) fn migrate_schema<C: CanisterKind>(
750 db: &Db<C>,
751 proposal: &SchemaProposal,
752 command: SchemaMigrationCommand,
753) -> Result<SchemaMigrationStatusPage, InternalError> {
754 ensure_recovery_admitted(db)?;
755 match command {
756 SchemaMigrationCommand::Adopt {
757 expected_database,
758 expected_head,
759 } => adopt_entity_source_lineage(db, proposal, expected_database, &expected_head),
760 SchemaMigrationCommand::Advance {
761 expected_database,
762 expected_head,
763 expected_plan,
764 acknowledged_finding_page,
765 } => advance_metadata_schema_migration(
766 db,
767 proposal,
768 expected_database,
769 &expected_head,
770 expected_plan,
771 acknowledged_finding_page,
772 ),
773 SchemaMigrationCommand::Abort {
774 expected_database,
775 expected_head,
776 expected_plan,
777 } => {
778 let plan = proposal.migration().ok_or_else(|| {
779 InternalError::schema_migration(SchemaMigrationCode::MissingMigration)
780 })?;
781 if proposal.target_database() != expected_database || plan.digest() != expected_plan {
782 return Err(InternalError::schema_migration(
783 SchemaMigrationCode::PlanChanged,
784 ));
785 }
786 if let Some(record) = exact_active_migration_record(
787 proposal,
788 expected_database,
789 &expected_head,
790 expected_plan,
791 )? {
792 let target = schema_application_target(db)?;
793 validate_active_migration_target(&record, &target)?;
794 if record.phase() == PersistedSchemaMigrationPhase::Applied
795 || record.phase() == PersistedSchemaMigrationPhase::Aborted
796 {
797 return active_migration_status(proposal, &target, &record);
798 }
799 if !record.phase().abortable() {
800 return Err(InternalError::schema_migration(
801 SchemaMigrationCode::AbortTooLate,
802 ));
803 }
804 let planned = recompile_active_physical_migration(db, proposal, &record)?;
805 let authorities = application_authorities(db);
806 let store_identities = authorities
807 .iter()
808 .map(|authority| {
809 (
810 authority.path,
811 derive_store_identity(record.database_identity(), authority),
812 )
813 })
814 .collect::<BTreeMap<_, _>>();
815 let (progress, exhausted) = cleanup_migration_staging_page(
816 db,
817 &planned,
818 record.progress(),
819 &store_identities,
820 )?;
821 let phase = if exhausted {
822 PersistedSchemaMigrationPhase::Aborted
823 } else {
824 record.phase()
825 };
826 let advanced = record.transition(phase, progress)?;
827 let operation = SchemaMigrationRecordOp::replace(&record, &advanced)?;
828 publish_accepted_schema_candidates_with_database_control(
829 Vec::new(),
830 vec![DatabaseControlOp::SchemaMigration(operation)],
831 )?;
832 return active_migration_status(proposal, &target, &advanced);
833 }
834 let target = exact_migration_target(db, expected_database, &expected_head)?;
835 let status = schema_migration_status_for_target(db, proposal, &target)?;
836 if status.phase() == SchemaMigrationPhase::Applied {
837 Ok(status)
838 } else {
839 Err(InternalError::schema_migration(
842 SchemaMigrationCode::MissingMigration,
843 ))
844 }
845 }
846 }
847}
848
849#[cfg(feature = "migration")]
851pub(in crate::db) fn schema_migration_status<C: CanisterKind>(
852 db: &Db<C>,
853 proposal: &SchemaProposal,
854 request: &SchemaMigrationStatusRequest,
855) -> Result<SchemaMigrationStatusPage, InternalError> {
856 ensure_recovery_admitted(db)?;
857 if !request.validate() || request.cursor().is_some() {
858 return Err(InternalError::cursor_invalid_continuation());
859 }
860 let target = schema_application_target(db)?;
861 schema_migration_status_for_target(db, proposal, &target)
862}
863
864#[cfg(feature = "migration")]
868pub(in crate::db) fn defer_generated_schema_application_for_prepared_migration<C: CanisterKind>(
869 db: &Db<C>,
870 proposal: &SchemaProposal,
871) -> Result<bool, InternalError> {
872 ensure_recovery_admitted(db)?;
873 let Some(record) = load_schema_migration_record()? else {
874 return Ok(false);
875 };
876 if matches!(
877 record.phase(),
878 PersistedSchemaMigrationPhase::Applied | PersistedSchemaMigrationPhase::Aborted
879 ) {
880 return Ok(false);
881 }
882 validate_active_migration_deployment(proposal, &record)?;
883 let target = schema_application_target(db)?;
884 validate_active_migration_target(&record, &target)?;
885 match record.phase() {
886 PersistedSchemaMigrationPhase::Prepared => Ok(true),
887 PersistedSchemaMigrationPhase::Validating
888 | PersistedSchemaMigrationPhase::ReadyToRewrite
889 | PersistedSchemaMigrationPhase::RewritingRows
890 | PersistedSchemaMigrationPhase::RebuildingIndexes
891 | PersistedSchemaMigrationPhase::FinalValidation
892 | PersistedSchemaMigrationPhase::Publishing
893 | PersistedSchemaMigrationPhase::Rejected => Err(InternalError::schema_migration(
894 SchemaMigrationCode::MigrationInProgress,
895 )),
896 PersistedSchemaMigrationPhase::Applied | PersistedSchemaMigrationPhase::Aborted => {
897 Ok(false)
898 }
899 }
900}
901
902#[cfg(feature = "migration")]
903fn adopt_entity_source_lineage<C: CanisterKind>(
904 db: &Db<C>,
905 proposal: &SchemaProposal,
906 expected_database: TargetDatabaseIdentity,
907 expected_head: &ExpectedAcceptedHead,
908) -> Result<SchemaMigrationStatusPage, InternalError> {
909 if proposal.migration().is_some() || proposal.target_database() != expected_database {
910 return Err(InternalError::schema_migration(
911 SchemaMigrationCode::PlanChanged,
912 ));
913 }
914 let proposal_digest = proposal
915 .digest()
916 .map_err(|_| InternalError::store_unsupported())?;
917 let submission_key = migration_submission_key(None)?;
918 if let Some(record) = load_exact_migration_record(
919 expected_database,
920 &submission_key,
921 proposal_digest,
922 expected_head,
923 )? {
924 let replay_target = exact_migration_replay_target(db, expected_database, &record)?;
925 return schema_migration_status_for_target(db, proposal, &replay_target);
926 }
927 let target = exact_migration_target(db, expected_database, expected_head)?;
928
929 let authorities = application_authorities(db);
930 let current_bundles = load_current_application_bundles(authorities.as_slice())?;
931 let stores = existing_proposal_stores(
932 target.database_identity(),
933 authorities.as_slice(),
934 current_bundles.as_slice(),
935 );
936 let stored_before = load_entity_source_lineage_catalog()?;
937 let before = stored_before.clone().unwrap_or_default();
938 let planned = plan_entity_source_adoption(proposal, stores.as_slice(), &before)
939 .map_err(schema_migration_planning_error)?;
940 let after = lineage_after_planned(&before, planned.as_slice(), expected_head)?;
941 let receipt = SchemaChangeReceipt::new(
942 expected_database,
943 submission_key,
944 proposal_digest,
945 expected_head.clone(),
946 SchemaChangeOutcome::NoOp {
947 accepted_head: expected_head.clone(),
948 },
949 )?;
950 let record = SchemaApplicationRecord::new(receipt, Vec::new())?;
951 let operation = SchemaApplicationRecordOp::insert(&record)?;
952 let lineage = EntitySourceLineageCatalogOp::replace(stored_before.as_ref(), &after)?;
953 publish_accepted_schema_candidates_with_database_control(
954 Vec::new(),
955 vec![
956 DatabaseControlOp::SchemaApplication(operation),
957 DatabaseControlOp::EntitySourceLineage(lineage),
958 ],
959 )?;
960 schema_migration_status_for_target(db, proposal, &target)
961}
962
963#[cfg(feature = "migration")]
964#[expect(
965 clippy::too_many_lines,
966 reason = "one migration entry point keeps preparation and exact replay ordering visible"
967)]
968fn advance_metadata_schema_migration<C: CanisterKind>(
969 db: &Db<C>,
970 proposal: &SchemaProposal,
971 expected_database: TargetDatabaseIdentity,
972 expected_head: &ExpectedAcceptedHead,
973 expected_plan: SchemaMigrationPlanDigest,
974 acknowledged_finding_page: Option<u64>,
975) -> Result<SchemaMigrationStatusPage, InternalError> {
976 let plan = proposal
977 .migration()
978 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::MissingMigration))?;
979 if proposal.target_database() != expected_database || plan.digest() != expected_plan {
980 return Err(InternalError::schema_migration(
981 SchemaMigrationCode::PlanChanged,
982 ));
983 }
984 let proposal_digest = proposal
985 .digest()
986 .map_err(|_| InternalError::store_unsupported())?;
987 if let Some(record) =
988 exact_active_migration_record(proposal, expected_database, expected_head, expected_plan)?
989 {
990 let target = schema_application_target(db)?;
991 validate_active_migration_target(&record, &target)?;
992 return advance_active_schema_migration(
993 db,
994 proposal,
995 &target,
996 &record,
997 acknowledged_finding_page,
998 );
999 }
1000 if acknowledged_finding_page.is_some() {
1001 return Err(InternalError::schema_migration(
1002 SchemaMigrationCode::CandidateMismatch,
1003 ));
1004 }
1005 let submission_key = migration_submission_key(Some(expected_plan))?;
1006 if let Some(record) = load_exact_migration_record(
1007 expected_database,
1008 &submission_key,
1009 proposal_digest,
1010 expected_head,
1011 )? {
1012 let replay_target = exact_migration_replay_target(db, expected_database, &record)?;
1013 return schema_migration_status_for_target(db, proposal, &replay_target);
1014 }
1015 exact_migration_target(db, expected_database, expected_head)?;
1016
1017 let authorities = application_authorities(db);
1018 let current_bundles = load_current_application_bundles(authorities.as_slice())?;
1019 let stores = existing_proposal_stores(
1020 expected_database,
1021 authorities.as_slice(),
1022 current_bundles.as_slice(),
1023 );
1024 let before = load_entity_source_lineage_catalog()?
1025 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::Unadopted))?;
1026 let planned = plan_schema_migration(proposal, stores.as_slice(), &before)
1027 .map_err(schema_migration_planning_error)?;
1028 let mut candidates = planned.candidates().to_vec();
1029 let pending = if planned.requires_physical_validation() {
1030 None
1035 } else {
1036 preflight_existing_application(
1037 authorities.as_slice(),
1038 current_bundles.as_slice(),
1039 &mut candidates,
1040 )?
1041 };
1042 if pending.is_some() {
1043 return Err(InternalError::schema_migration(
1044 SchemaMigrationCode::MigrationInProgress,
1045 ));
1046 }
1047 if candidates.is_empty() || planned.lineage().is_empty() {
1048 return Err(InternalError::schema_migration(
1049 SchemaMigrationCode::EmptyEntityVersionBump,
1050 ));
1051 }
1052 validate_database_identity_state_capacity(
1053 authorities.as_slice(),
1054 candidates.as_slice(),
1055 database_incarnation_id()?,
1056 )?;
1057 let accepted_head =
1058 accepted_head_after_candidates(authorities.as_slice(), candidates.as_slice())?;
1059 if planned.requires_physical_validation() {
1060 ensure_physical_migration_stores_are_journaled(db, &planned)?;
1061 let record = prepared_physical_schema_migration(
1062 proposal,
1063 &planned,
1064 candidates.as_slice(),
1065 expected_database,
1066 expected_head,
1067 &accepted_head,
1068 proposal_digest,
1069 expected_plan,
1070 stores.as_slice(),
1071 )?;
1072 let operation = SchemaMigrationRecordOp::insert(&record)?;
1073 publish_accepted_schema_candidates_with_database_control(
1074 Vec::new(),
1075 vec![DatabaseControlOp::SchemaMigration(operation)],
1076 )?;
1077 let target = schema_application_target(db)?;
1078 validate_active_migration_target(&record, &target)?;
1079 return active_migration_status(proposal, &target, &record);
1080 }
1081 let after = lineage_after_planned(&before, planned.lineage(), &accepted_head)?;
1082 let receipt = SchemaChangeReceipt::new(
1083 expected_database,
1084 submission_key,
1085 proposal_digest,
1086 expected_head.clone(),
1087 SchemaChangeOutcome::Applied { accepted_head },
1088 )?;
1089 let record = SchemaApplicationRecord::new(receipt, Vec::new())?;
1090 let operation = SchemaApplicationRecordOp::insert(&record)?;
1091 let lineage = EntitySourceLineageCatalogOp::replace(Some(&before), &after)?;
1092 let publications = application_publications(
1093 authorities.as_slice(),
1094 current_bundles.as_slice(),
1095 candidates.as_slice(),
1096 )?;
1097 publish_accepted_schema_candidates_with_database_control(
1098 publications,
1099 vec![
1100 DatabaseControlOp::SchemaApplication(operation),
1101 DatabaseControlOp::EntitySourceLineage(lineage),
1102 ],
1103 )?;
1104 let applied_target = schema_application_target(db)?;
1105 schema_migration_status_for_target(db, proposal, &applied_target)
1106}
1107
1108#[cfg(feature = "migration")]
1109fn ensure_physical_migration_stores_are_journaled<C: CanisterKind>(
1110 db: &Db<C>,
1111 planned: &crate::db::schema::migration_planner::PlannedSchemaMigration,
1112) -> Result<(), InternalError> {
1113 for program in planned.programs() {
1114 let store = db.store_handle(program.store_path())?;
1115 if store.storage_capabilities().recovery()
1116 != StoreRecoveryCapability::StableBasePlusJournalReplay
1117 {
1118 return Err(InternalError::schema_migration(
1119 SchemaMigrationCode::PhysicalRunnerMissing,
1120 ));
1121 }
1122 }
1123 Ok(())
1124}
1125
1126#[cfg(feature = "migration")]
1127#[expect(
1128 clippy::too_many_arguments,
1129 reason = "migration preparation binds every immutable deployment and candidate identity"
1130)]
1131fn prepared_physical_schema_migration(
1132 proposal: &SchemaProposal,
1133 planned: &crate::db::schema::migration_planner::PlannedSchemaMigration,
1134 candidates: &[CandidateSchemaRevision],
1135 database_identity: TargetDatabaseIdentity,
1136 accepted_before: &ExpectedAcceptedHead,
1137 candidate_head: &ExpectedAcceptedHead,
1138 submission_digest: SchemaProposalDigest,
1139 plan_digest: SchemaMigrationPlanDigest,
1140 stores: &[ExistingProposalStore<'_>],
1141) -> Result<SchemaMigrationRecord, InternalError> {
1142 let plan = proposal
1143 .migration()
1144 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::MissingMigration))?;
1145 let transitions = plan
1146 .transitions()
1147 .iter()
1148 .map(|transition| {
1149 PersistedSchemaMigrationTransition::try_new(
1150 transition.entity().clone(),
1151 transition.from().get(),
1152 transition
1153 .from()
1154 .get()
1155 .checked_add(1)
1156 .ok_or_else(InternalError::store_invariant)?,
1157 )
1158 })
1159 .collect::<Result<Vec<_>, InternalError>>()?;
1160 let entities = planned
1161 .lineage()
1162 .iter()
1163 .map(|entity| {
1164 PersistedSchemaMigrationEntity::try_new(
1165 entity.store(),
1166 entity.entity(),
1167 entity.digest(),
1168 )
1169 })
1170 .collect::<Result<Vec<_>, InternalError>>()?;
1171 let mut staged_indexes = Vec::new();
1172 for candidate in candidates {
1173 let store = stores
1174 .iter()
1175 .find(|store| store.path == candidate.store_path())
1176 .ok_or_else(InternalError::store_invariant)?;
1177 for (entity, snapshot) in candidate.bundle().entity_snapshots() {
1178 let before = store.bundle.entity_snapshots().get(entity);
1179 for index in snapshot
1180 .indexes()
1181 .iter()
1182 .filter(|index| {
1183 before
1184 .and_then(|before| {
1185 before
1186 .indexes()
1187 .iter()
1188 .find(|old| old.schema_id() == index.schema_id())
1189 })
1190 .is_none_or(|old| old.physical_generation() != index.physical_generation())
1191 })
1192 .chain(snapshot.candidate_indexes())
1193 {
1194 staged_indexes.push(PersistedSchemaMigrationIndex::try_new(
1195 store.identity,
1196 *entity,
1197 u64::from(index.schema_id().get()),
1198 index.physical_generation(),
1199 )?);
1200 }
1201 }
1202 }
1203 staged_indexes.sort_unstable();
1204 staged_indexes.dedup();
1205 SchemaMigrationRecord::prepared(
1206 database_identity,
1207 accepted_before.clone(),
1208 candidate_head.clone(),
1209 submission_digest,
1210 plan_digest,
1211 transitions,
1212 entities,
1213 staged_indexes,
1214 )
1215}
1216
1217#[cfg(feature = "migration")]
1218#[expect(
1219 clippy::too_many_lines,
1220 reason = "the closed phase match keeps every durable migration transition and publication boundary exhaustive"
1221)]
1222fn advance_active_schema_migration<C: CanisterKind>(
1223 db: &Db<C>,
1224 proposal: &SchemaProposal,
1225 target: &SchemaApplicationTarget,
1226 record: &SchemaMigrationRecord,
1227 acknowledged_finding_page: Option<u64>,
1228) -> Result<SchemaMigrationStatusPage, InternalError> {
1229 match record.phase() {
1230 PersistedSchemaMigrationPhase::Prepared => {
1231 if acknowledged_finding_page.is_some() {
1232 return Err(InternalError::schema_migration(
1233 SchemaMigrationCode::CandidateMismatch,
1234 ));
1235 }
1236 let validating = record.transition(
1237 PersistedSchemaMigrationPhase::Validating,
1238 record.progress().clone(),
1239 )?;
1240 publish_migration_record_replacement(record, &validating)?;
1241 active_migration_status(proposal, target, &validating)
1242 }
1243 PersistedSchemaMigrationPhase::Validating => {
1244 if acknowledged_finding_page.is_some() {
1245 return Err(InternalError::schema_migration(
1246 SchemaMigrationCode::CandidateMismatch,
1247 ));
1248 }
1249 let planned = recompile_active_physical_migration(db, proposal, record)?;
1250 let page = validate_migration_page(db, &planned, record.progress())?;
1251 let (progress, staged_entries, exhausted) = page.into_parts();
1252 let phase = if progress.findings().is_empty() {
1253 if exhausted {
1254 PersistedSchemaMigrationPhase::ReadyToRewrite
1255 } else {
1256 PersistedSchemaMigrationPhase::Validating
1257 }
1258 } else {
1259 PersistedSchemaMigrationPhase::Rejected
1260 };
1261 if progress.findings().is_empty() {
1262 stage_migration_index_entries(staged_entries)?;
1266 }
1267 let advanced = record.transition(phase, progress)?;
1268 publish_migration_record_replacement(record, &advanced)?;
1269 active_migration_status(proposal, target, &advanced)
1270 }
1271 PersistedSchemaMigrationPhase::Rejected => {
1272 if acknowledged_finding_page.is_some()
1273 && acknowledged_finding_page != record.progress().finding_page()
1274 {
1275 return Err(InternalError::schema_migration(
1276 SchemaMigrationCode::CandidateMismatch,
1277 ));
1278 }
1279 active_migration_status(proposal, target, record)
1280 }
1281 PersistedSchemaMigrationPhase::ReadyToRewrite => {
1282 if acknowledged_finding_page.is_some() {
1283 return Err(InternalError::schema_migration(
1284 SchemaMigrationCode::CandidateMismatch,
1285 ));
1286 }
1287 let progress = record.progress().begin_row_phase()?;
1288 let rewriting =
1289 record.transition(PersistedSchemaMigrationPhase::RewritingRows, progress)?;
1290 publish_migration_record_replacement(record, &rewriting)?;
1291 active_migration_status(proposal, target, &rewriting)
1292 }
1293 PersistedSchemaMigrationPhase::RewritingRows => {
1294 if acknowledged_finding_page.is_some() {
1295 return Err(InternalError::schema_migration(
1296 SchemaMigrationCode::CandidateMismatch,
1297 ));
1298 }
1299 let planned = recompile_active_physical_migration(db, proposal, record)?;
1300 let page =
1301 rewrite_migration_page(db, &planned, record.progress(), record.plan_digest())?;
1302 let (progress, effects, exhausted) = page.into_parts();
1303 if progress.rows_rewritten() > progress.rows_validated()
1304 || (exhausted && progress.rows_rewritten() != progress.rows_validated())
1305 {
1306 return Err(InternalError::schema_migration(
1307 SchemaMigrationCode::ProgressCorrupt,
1308 ));
1309 }
1310 let phase = if exhausted {
1311 PersistedSchemaMigrationPhase::RebuildingIndexes
1312 } else {
1313 PersistedSchemaMigrationPhase::RewritingRows
1314 };
1315 let advanced = record.transition(phase, progress)?;
1316 let operation = SchemaMigrationRecordOp::replace(record, &advanced)?;
1317 publish_migration_rewrite_page(effects, operation)?;
1318 active_migration_status(proposal, target, &advanced)
1319 }
1320 PersistedSchemaMigrationPhase::RebuildingIndexes => {
1321 if acknowledged_finding_page.is_some() {
1322 return Err(InternalError::schema_migration(
1323 SchemaMigrationCode::CandidateMismatch,
1324 ));
1325 }
1326 let planned = recompile_active_physical_migration(db, proposal, record)?;
1327 let rebuilt = migration_derived_domain_count(db, &planned)?;
1328 let progress = record
1329 .progress()
1330 .begin_row_phase()?
1331 .with_index_progress(None, rebuilt)?;
1332 let validating =
1333 record.transition(PersistedSchemaMigrationPhase::FinalValidation, progress)?;
1334 publish_migration_record_replacement(record, &validating)?;
1335 active_migration_status(proposal, target, &validating)
1336 }
1337 PersistedSchemaMigrationPhase::FinalValidation => {
1338 if acknowledged_finding_page.is_some() {
1339 return Err(InternalError::schema_migration(
1340 SchemaMigrationCode::CandidateMismatch,
1341 ));
1342 }
1343 let planned = recompile_active_physical_migration(db, proposal, record)?;
1344 let page = final_validate_migration_page(db, &planned, record.progress())?;
1345 let (progress, exhausted) = page.into_parts();
1346 let phase = if exhausted {
1347 PersistedSchemaMigrationPhase::Publishing
1348 } else {
1349 PersistedSchemaMigrationPhase::FinalValidation
1350 };
1351 let advanced = record.transition(phase, progress)?;
1352 publish_migration_record_replacement(record, &advanced)?;
1353 active_migration_status(proposal, target, &advanced)
1354 }
1355 PersistedSchemaMigrationPhase::Publishing => {
1356 if acknowledged_finding_page.is_some() {
1357 return Err(InternalError::schema_migration(
1358 SchemaMigrationCode::CandidateMismatch,
1359 ));
1360 }
1361 publish_completed_physical_migration(db, proposal, record)
1362 }
1363 PersistedSchemaMigrationPhase::Applied | PersistedSchemaMigrationPhase::Aborted => {
1364 if acknowledged_finding_page.is_some() {
1365 return Err(InternalError::schema_migration(
1366 SchemaMigrationCode::CandidateMismatch,
1367 ));
1368 }
1369 active_migration_status(proposal, target, record)
1370 }
1371 }
1372}
1373
1374#[cfg(feature = "migration")]
1375fn recompile_active_physical_migration<C: CanisterKind>(
1376 db: &Db<C>,
1377 proposal: &SchemaProposal,
1378 record: &SchemaMigrationRecord,
1379) -> Result<crate::db::schema::migration_planner::PlannedSchemaMigration, InternalError> {
1380 let authorities = application_authorities(db);
1381 let current_bundles = load_current_application_bundles(authorities.as_slice())?;
1382 let stores = existing_proposal_stores(
1383 record.database_identity(),
1384 authorities.as_slice(),
1385 current_bundles.as_slice(),
1386 );
1387 let lineage = load_entity_source_lineage_catalog()?
1388 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::Unadopted))?;
1389 let planned = plan_schema_migration(proposal, stores.as_slice(), &lineage)
1390 .map_err(schema_migration_planning_error)?;
1391 if !planned.requires_physical_validation() {
1392 return Err(InternalError::schema_migration(
1393 SchemaMigrationCode::PlanChanged,
1394 ));
1395 }
1396 let candidate_head =
1397 accepted_head_after_candidates(authorities.as_slice(), planned.candidates())?;
1398 if &candidate_head != record.candidate_head() {
1399 return Err(InternalError::schema_migration(
1400 SchemaMigrationCode::CandidateMismatch,
1401 ));
1402 }
1403 Ok(planned)
1404}
1405
1406#[cfg(feature = "migration")]
1407fn publish_completed_physical_migration<C: CanisterKind>(
1408 db: &Db<C>,
1409 proposal: &SchemaProposal,
1410 record: &SchemaMigrationRecord,
1411) -> Result<SchemaMigrationStatusPage, InternalError> {
1412 let target = schema_application_target(db)?;
1413 validate_active_migration_target(record, &target)?;
1414 let planned = recompile_active_physical_migration(db, proposal, record)?;
1415 let authorities = application_authorities(db);
1416 let current_bundles = load_current_application_bundles(authorities.as_slice())?;
1417 let candidate_head =
1418 accepted_head_after_candidates(authorities.as_slice(), planned.candidates())?;
1419 if &candidate_head != record.candidate_head() {
1420 return Err(InternalError::schema_migration(
1421 SchemaMigrationCode::PublicationRaceLost,
1422 ));
1423 }
1424 let lineage_before = load_entity_source_lineage_catalog()?
1425 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::Unadopted))?;
1426 let lineage_after =
1427 lineage_after_planned(&lineage_before, planned.lineage(), record.candidate_head())?;
1428 let receipt = SchemaChangeReceipt::new(
1429 record.database_identity(),
1430 migration_submission_key(Some(record.plan_digest()))?,
1431 record.submission_digest(),
1432 record.accepted_before().clone(),
1433 SchemaChangeOutcome::Applied {
1434 accepted_head: record.candidate_head().clone(),
1435 },
1436 )?;
1437 let application = SchemaApplicationRecord::new(receipt, Vec::new())?;
1438 let application = SchemaApplicationRecordOp::insert(&application)?;
1439 let lineage = EntitySourceLineageCatalogOp::replace(Some(&lineage_before), &lineage_after)?;
1440 let applied = record.transition(
1441 PersistedSchemaMigrationPhase::Applied,
1442 record.progress().clone(),
1443 )?;
1444 let migration = SchemaMigrationRecordOp::replace(record, &applied)?;
1445 let publications = application_publications(
1446 authorities.as_slice(),
1447 current_bundles.as_slice(),
1448 planned.candidates(),
1449 )?;
1450 publish_accepted_schema_candidates_with_database_control(
1451 publications,
1452 vec![
1453 DatabaseControlOp::SchemaApplication(application),
1454 DatabaseControlOp::EntitySourceLineage(lineage),
1455 DatabaseControlOp::SchemaMigration(migration),
1456 ],
1457 )?;
1458 db.mark_all_registered_index_stores_ready()?;
1459 let applied_target = schema_application_target(db)?;
1460 active_migration_status(proposal, &applied_target, &applied)
1461}
1462
1463#[cfg(feature = "migration")]
1464fn publish_migration_record_replacement(
1465 before: &SchemaMigrationRecord,
1466 after: &SchemaMigrationRecord,
1467) -> Result<(), InternalError> {
1468 let operation = SchemaMigrationRecordOp::replace(before, after)?;
1469 publish_accepted_schema_candidates_with_database_control(
1470 Vec::new(),
1471 vec![DatabaseControlOp::SchemaMigration(operation)],
1472 )
1473}
1474
1475#[cfg(feature = "migration")]
1476fn attach_ordinary_lineage_publication(
1477 proposal: &SchemaProposal,
1478 prior_head: &ExpectedAcceptedHead,
1479 accepted_head: &ExpectedAcceptedHead,
1480 candidates: &[CandidateSchemaRevision],
1481 operation: SchemaApplicationRecordOp,
1482) -> Result<Vec<DatabaseControlOp>, InternalError> {
1483 let mut operations = vec![DatabaseControlOp::SchemaApplication(operation)];
1484 let stored_before = load_entity_source_lineage_catalog()?;
1485 let planned = if matches!(prior_head, ExpectedAcceptedHead::Empty) {
1486 plan_initial_entity_source_lineage(proposal, candidates)
1487 .map_err(schema_migration_planning_error)?
1488 } else {
1489 Vec::new()
1490 };
1491 if planned.is_empty() && (stored_before.is_none() || prior_head == accepted_head) {
1492 return Ok(operations);
1493 }
1494 let before = stored_before.clone().unwrap_or_default();
1495 let after = lineage_after_planned(&before, planned.as_slice(), accepted_head)?;
1496 if before == after {
1497 return Ok(operations);
1498 }
1499 operations.push(DatabaseControlOp::EntitySourceLineage(
1500 EntitySourceLineageCatalogOp::replace(stored_before.as_ref(), &after)?,
1501 ));
1502 Ok(operations)
1503}
1504
1505#[cfg(feature = "migration")]
1506fn exact_migration_target<C: CanisterKind>(
1507 db: &Db<C>,
1508 expected_database: TargetDatabaseIdentity,
1509 expected_head: &ExpectedAcceptedHead,
1510) -> Result<SchemaApplicationTarget, InternalError> {
1511 let target = schema_application_target(db)?;
1512 if target.database_identity() != expected_database || target.accepted_head() != expected_head {
1513 return Err(InternalError::schema_migration(
1514 SchemaMigrationCode::StaleAcceptedHead,
1515 ));
1516 }
1517 Ok(target)
1518}
1519
1520#[cfg(feature = "migration")]
1521fn exact_migration_replay_target<C: CanisterKind>(
1522 db: &Db<C>,
1523 expected_database: TargetDatabaseIdentity,
1524 record: &SchemaApplicationRecord,
1525) -> Result<SchemaApplicationTarget, InternalError> {
1526 let target = schema_application_target(db)?;
1527 if target.database_identity() != expected_database
1528 || target.accepted_head() != migration_record_accepted_head(record)?
1529 {
1530 return Err(InternalError::schema_migration(
1531 SchemaMigrationCode::PlanChanged,
1532 ));
1533 }
1534 Ok(target)
1535}
1536
1537#[cfg(feature = "migration")]
1538fn schema_migration_status_for_target<C: CanisterKind>(
1539 db: &Db<C>,
1540 proposal: &SchemaProposal,
1541 target: &SchemaApplicationTarget,
1542) -> Result<SchemaMigrationStatusPage, InternalError> {
1543 if let Some(record) = load_schema_migration_record()? {
1544 validate_active_migration_deployment(proposal, &record)?;
1545 validate_active_migration_target(&record, target)?;
1546 return active_migration_status(proposal, target, &record);
1547 }
1548 let lineage = load_entity_source_lineage_catalog()?.unwrap_or_default();
1549 let plan_digest = proposal
1550 .migration()
1551 .map(icydb_schema::SchemaMigrationPlan::digest);
1552 let transitions = migration_transitions(proposal)?;
1553 let submission_key = migration_submission_key(plan_digest)?;
1554 let terminal = load_migration_record_for_status(target.database_identity(), &submission_key)?
1555 .map(|record| public_migration_receipt(&record, plan_digest))
1556 .transpose()?;
1557 let unadopted = lineage.entries().is_empty()
1558 || lineage
1559 .entries()
1560 .values()
1561 .any(|entry| matches!(entry.state(), AcceptedEntitySourceLineageState::Unadopted));
1562 let applied = current_proposal_lineage_is_applied(db, proposal, target.accepted_head())?;
1563 let phase = if unadopted {
1564 SchemaMigrationPhase::Unadopted
1565 } else if proposal.migration().is_none() {
1566 SchemaMigrationPhase::Adopted
1567 } else if applied {
1568 SchemaMigrationPhase::Applied
1569 } else {
1570 SchemaMigrationPhase::Idle
1571 };
1572 let terminal = terminal.filter(|receipt| {
1573 receipt.accepted_head() == target.accepted_head()
1574 && receipt.plan_digest() == plan_digest
1575 && matches!(
1576 phase,
1577 SchemaMigrationPhase::Adopted | SchemaMigrationPhase::Applied
1578 )
1579 });
1580 Ok(SchemaMigrationStatusPage::new(
1581 target.database_identity(),
1582 target.accepted_head().clone(),
1583 plan_digest,
1584 phase,
1585 transitions,
1586 0,
1587 0,
1588 0,
1589 Vec::new(),
1590 None,
1591 terminal,
1592 ))
1593}
1594
1595#[cfg(feature = "migration")]
1596fn exact_active_migration_record(
1597 proposal: &SchemaProposal,
1598 expected_database: TargetDatabaseIdentity,
1599 expected_head: &ExpectedAcceptedHead,
1600 expected_plan: SchemaMigrationPlanDigest,
1601) -> Result<Option<SchemaMigrationRecord>, InternalError> {
1602 let Some(record) = load_schema_migration_record()? else {
1603 return Ok(None);
1604 };
1605 if record.database_identity() != expected_database
1606 || record.accepted_before() != expected_head
1607 || record.plan_digest() != expected_plan
1608 {
1609 return Err(InternalError::schema_migration(
1610 SchemaMigrationCode::PlanChanged,
1611 ));
1612 }
1613 validate_active_migration_deployment(proposal, &record)?;
1614 Ok(Some(record))
1615}
1616
1617#[cfg(feature = "migration")]
1618fn validate_active_migration_deployment(
1619 proposal: &SchemaProposal,
1620 record: &SchemaMigrationRecord,
1621) -> Result<(), InternalError> {
1622 let plan = proposal
1623 .migration()
1624 .ok_or_else(|| InternalError::schema_migration(SchemaMigrationCode::PlanChanged))?;
1625 let proposal_digest = proposal
1626 .digest()
1627 .map_err(|_| InternalError::store_unsupported())?;
1628 if proposal.target_database() != record.database_identity()
1629 || plan.digest() != record.plan_digest()
1630 || proposal_digest != record.submission_digest()
1631 {
1632 return Err(InternalError::schema_migration(
1633 SchemaMigrationCode::PlanChanged,
1634 ));
1635 }
1636 Ok(())
1637}
1638
1639#[cfg(feature = "migration")]
1640fn validate_active_migration_target(
1641 record: &SchemaMigrationRecord,
1642 target: &SchemaApplicationTarget,
1643) -> Result<(), InternalError> {
1644 let expected_head = if record.phase() == PersistedSchemaMigrationPhase::Applied {
1645 record.candidate_head()
1646 } else {
1647 record.accepted_before()
1648 };
1649 if target.database_identity() != record.database_identity()
1650 || target.accepted_head() != expected_head
1651 {
1652 return Err(InternalError::schema_migration(
1653 SchemaMigrationCode::PlanChanged,
1654 ));
1655 }
1656 Ok(())
1657}
1658
1659#[cfg(feature = "migration")]
1660fn active_migration_status(
1661 proposal: &SchemaProposal,
1662 target: &SchemaApplicationTarget,
1663 record: &SchemaMigrationRecord,
1664) -> Result<SchemaMigrationStatusPage, InternalError> {
1665 validate_active_migration_deployment(proposal, record)?;
1666 validate_active_migration_target(record, target)?;
1667 let transitions = record
1668 .transitions()
1669 .iter()
1670 .map(|transition| {
1671 SchemaMigrationEntityTransition::new(
1672 transition.entity().clone(),
1673 Some(transition.predecessor_version()),
1674 transition.target_version(),
1675 )
1676 })
1677 .collect();
1678 let findings = record
1679 .progress()
1680 .findings()
1681 .iter()
1682 .map(|finding| {
1683 let kind = match finding.kind() {
1684 PersistedSchemaMigrationFindingKind::Transform => {
1685 SchemaMigrationFindingKind::Transform
1686 }
1687 PersistedSchemaMigrationFindingKind::UniqueIndex => {
1688 SchemaMigrationFindingKind::UniqueIndex
1689 }
1690 PersistedSchemaMigrationFindingKind::Relation => {
1691 SchemaMigrationFindingKind::Relation
1692 }
1693 PersistedSchemaMigrationFindingKind::Constraint => {
1694 SchemaMigrationFindingKind::Constraint
1695 }
1696 };
1697 SchemaMigrationFinding::new(
1698 kind,
1699 finding.entity().value(),
1700 finding.primary_key().to_vec(),
1701 )
1702 })
1703 .collect();
1704 let phase = match record.phase() {
1705 PersistedSchemaMigrationPhase::Prepared => SchemaMigrationPhase::Prepared,
1706 PersistedSchemaMigrationPhase::Validating => SchemaMigrationPhase::Validating,
1707 PersistedSchemaMigrationPhase::ReadyToRewrite => SchemaMigrationPhase::ReadyToRewrite,
1708 PersistedSchemaMigrationPhase::RewritingRows => SchemaMigrationPhase::RewritingRows,
1709 PersistedSchemaMigrationPhase::RebuildingIndexes => SchemaMigrationPhase::RebuildingIndexes,
1710 PersistedSchemaMigrationPhase::FinalValidation => SchemaMigrationPhase::FinalValidation,
1711 PersistedSchemaMigrationPhase::Publishing => SchemaMigrationPhase::Publishing,
1712 PersistedSchemaMigrationPhase::Applied => SchemaMigrationPhase::Applied,
1713 PersistedSchemaMigrationPhase::Rejected => SchemaMigrationPhase::Rejected,
1714 PersistedSchemaMigrationPhase::Aborted => SchemaMigrationPhase::Aborted,
1715 };
1716 let terminal_receipt = (record.phase() == PersistedSchemaMigrationPhase::Applied).then(|| {
1717 SchemaMigrationReceipt::new(
1718 record.database_identity(),
1719 Some(record.plan_digest()),
1720 record.accepted_before().clone(),
1721 record.candidate_head().clone(),
1722 )
1723 });
1724 Ok(SchemaMigrationStatusPage::new(
1725 record.database_identity(),
1726 target.accepted_head().clone(),
1727 Some(record.plan_digest()),
1728 phase,
1729 transitions,
1730 record.progress().rows_validated(),
1731 record.progress().rows_rewritten(),
1732 record.progress().indexes_rebuilt(),
1733 findings,
1734 None,
1735 terminal_receipt,
1736 ))
1737}
1738
1739#[cfg(feature = "migration")]
1740fn load_current_application_bundles(
1741 authorities: &[StoreApplicationAuthority],
1742) -> Result<Vec<Option<AcceptedSchemaRevisionBundle>>, InternalError> {
1743 authorities
1744 .iter()
1745 .map(|authority| {
1746 authority
1747 .handle
1748 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
1749 })
1750 .collect()
1751}
1752
1753#[cfg(feature = "migration")]
1754fn existing_proposal_stores<'a>(
1755 database_identity: TargetDatabaseIdentity,
1756 authorities: &[StoreApplicationAuthority],
1757 bundles: &'a [Option<AcceptedSchemaRevisionBundle>],
1758) -> Vec<ExistingProposalStore<'a>> {
1759 authorities
1760 .iter()
1761 .zip(bundles)
1762 .filter_map(|(authority, bundle)| {
1763 bundle.as_ref().map(|bundle| ExistingProposalStore {
1764 path: authority.path,
1765 identity: derive_store_identity(database_identity, authority),
1766 bundle,
1767 })
1768 })
1769 .collect()
1770}
1771
1772#[cfg(feature = "migration")]
1773fn lineage_after_planned(
1774 before: &AcceptedEntitySourceLineageCatalog,
1775 planned: &[PlannedEntitySourceLineage],
1776 accepted_head: &ExpectedAcceptedHead,
1777) -> Result<AcceptedEntitySourceLineageCatalog, InternalError> {
1778 let mut entries = BTreeMap::new();
1779 for (key, entry) in before.entries() {
1780 let next = match entry.state() {
1781 AcceptedEntitySourceLineageState::Unadopted => {
1782 AcceptedEntitySourceLineage::unadopted(accepted_head.clone())?
1783 }
1784 AcceptedEntitySourceLineageState::Adopted {
1785 version,
1786 source_digest,
1787 } => AcceptedEntitySourceLineage::adopted(
1788 accepted_head.clone(),
1789 *version,
1790 *source_digest,
1791 )?,
1792 };
1793 entries.insert(*key, next);
1794 }
1795 for next in planned {
1796 entries.insert(
1797 (next.store(), next.entity()),
1798 AcceptedEntitySourceLineage::adopted(
1799 accepted_head.clone(),
1800 next.version(),
1801 next.digest(),
1802 )?,
1803 );
1804 }
1805 AcceptedEntitySourceLineageCatalog::try_new(entries)
1806}
1807
1808#[cfg(feature = "migration")]
1809fn schema_migration_planning_error(error: SchemaMigrationPlanningError) -> InternalError {
1810 let reason = match error {
1811 SchemaMigrationPlanningError::Unadopted => SchemaMigrationCode::Unadopted,
1812 SchemaMigrationPlanningError::MissingMigration => SchemaMigrationCode::MissingMigration,
1813 SchemaMigrationPlanningError::VersionGap => SchemaMigrationCode::VersionGap,
1814 SchemaMigrationPlanningError::Downgrade => SchemaMigrationCode::Downgrade,
1815 SchemaMigrationPlanningError::EmptyEntityVersionBump => {
1816 SchemaMigrationCode::EmptyEntityVersionBump
1817 }
1818 SchemaMigrationPlanningError::StaleAcceptedHead => SchemaMigrationCode::StaleAcceptedHead,
1819 SchemaMigrationPlanningError::UnknownFromObject => SchemaMigrationCode::UnknownFromObject,
1820 SchemaMigrationPlanningError::UnknownToObject => SchemaMigrationCode::UnknownToObject,
1821 SchemaMigrationPlanningError::KindMismatch => SchemaMigrationCode::KindMismatch,
1822 SchemaMigrationPlanningError::IdentityConflict => SchemaMigrationCode::IdentityConflict,
1823 SchemaMigrationPlanningError::UnexplainedSchemaDifference => {
1824 SchemaMigrationCode::UnexplainedSchemaDifference
1825 }
1826 SchemaMigrationPlanningError::UnsupportedTransform => {
1827 SchemaMigrationCode::UnsupportedTransform
1828 }
1829 SchemaMigrationPlanningError::RekeyedCatalogInvalid
1830 | SchemaMigrationPlanningError::CandidateMismatch => SchemaMigrationCode::CandidateMismatch,
1831 SchemaMigrationPlanningError::CorruptLineage => SchemaMigrationCode::ProgressCorrupt,
1832 };
1833 InternalError::schema_migration(reason)
1834}
1835
1836#[cfg(feature = "migration")]
1837fn migration_submission_key(
1838 plan_digest: Option<SchemaMigrationPlanDigest>,
1839) -> Result<SchemaSubmissionKey, InternalError> {
1840 let mut hasher = new_hash_sha256_prefixed(SCHEMA_MIGRATION_SUBMISSION_PROFILE);
1841 match plan_digest {
1842 None => write_hash_tag_u8(&mut hasher, 0),
1843 Some(digest) => {
1844 write_hash_tag_u8(&mut hasher, 1);
1845 hasher.update(digest.to_bytes());
1846 }
1847 }
1848 let digest = finalize_hash_sha256(hasher);
1849 let mut encoded = String::with_capacity(80);
1850 encoded.push_str("migration/");
1851 for byte in digest {
1852 use std::fmt::Write as _;
1853 write!(&mut encoded, "{byte:02x}").map_err(|_| InternalError::store_invariant())?;
1854 }
1855 SchemaSubmissionKey::try_new(encoded).map_err(|_| InternalError::store_invariant())
1856}
1857
1858#[cfg(feature = "migration")]
1859fn load_migration_record_for_status(
1860 database_identity: TargetDatabaseIdentity,
1861 submission_key: &SchemaSubmissionKey,
1862) -> Result<Option<SchemaApplicationRecord>, InternalError> {
1863 let record =
1864 with_schema_application_store(|store| store.load(database_identity, submission_key))?;
1865 if record
1866 .as_ref()
1867 .is_some_and(|record| record.receipt().database_identity() != database_identity)
1868 {
1869 return Err(InternalError::schema_migration(
1870 SchemaMigrationCode::ProgressCorrupt,
1871 ));
1872 }
1873 Ok(record)
1874}
1875
1876#[cfg(feature = "migration")]
1877fn load_exact_migration_record(
1878 database_identity: TargetDatabaseIdentity,
1879 submission_key: &SchemaSubmissionKey,
1880 proposal_digest: SchemaProposalDigest,
1881 prior_head: &ExpectedAcceptedHead,
1882) -> Result<Option<SchemaApplicationRecord>, InternalError> {
1883 let Some(record) =
1884 with_schema_application_store(|store| store.load(database_identity, submission_key))?
1885 else {
1886 return Ok(None);
1887 };
1888 if !record.receipt().is_exact_submission(
1889 database_identity,
1890 submission_key,
1891 proposal_digest,
1892 prior_head,
1893 ) {
1894 return Err(InternalError::schema_migration(
1895 SchemaMigrationCode::PlanChanged,
1896 ));
1897 }
1898 Ok(Some(record))
1899}
1900
1901#[cfg(feature = "migration")]
1902fn public_migration_receipt(
1903 record: &SchemaApplicationRecord,
1904 plan_digest: Option<SchemaMigrationPlanDigest>,
1905) -> Result<SchemaMigrationReceipt, InternalError> {
1906 let accepted_head = migration_record_accepted_head(record)?.clone();
1907 Ok(SchemaMigrationReceipt::new(
1908 record.receipt().database_identity(),
1909 plan_digest,
1910 record.receipt().prior_head().clone(),
1911 accepted_head,
1912 ))
1913}
1914
1915#[cfg(feature = "migration")]
1916fn migration_record_accepted_head(
1917 record: &SchemaApplicationRecord,
1918) -> Result<&ExpectedAcceptedHead, InternalError> {
1919 match record.receipt().outcome() {
1920 SchemaChangeOutcome::NoOp { accepted_head }
1921 | SchemaChangeOutcome::Applied { accepted_head } => Ok(accepted_head),
1922 SchemaChangeOutcome::Pending { .. } | SchemaChangeOutcome::Aborted { .. } => Err(
1923 InternalError::schema_migration(SchemaMigrationCode::ProgressCorrupt),
1924 ),
1925 }
1926}
1927
1928#[cfg(feature = "migration")]
1929fn migration_transitions(
1930 proposal: &SchemaProposal,
1931) -> Result<Vec<SchemaMigrationEntityTransition>, InternalError> {
1932 if let Some(plan) = proposal.migration() {
1933 return plan
1934 .transitions()
1935 .iter()
1936 .map(|transition| {
1937 let target = proposal_entity(proposal, transition.entity())?;
1938 Ok(SchemaMigrationEntityTransition::new(
1939 transition.entity().clone(),
1940 Some(transition.from().get()),
1941 target.version().get(),
1942 ))
1943 })
1944 .collect();
1945 }
1946 proposal
1947 .fragments()
1948 .iter()
1949 .flat_map(icydb_schema::SchemaFragment::entities)
1950 .map(|entity| {
1951 Ok(SchemaMigrationEntityTransition::new(
1952 entity.source_key().clone(),
1953 None,
1954 entity.version().get(),
1955 ))
1956 })
1957 .collect()
1958}
1959
1960#[cfg(feature = "migration")]
1961fn proposal_entity<'a>(
1962 proposal: &'a SchemaProposal,
1963 source: &EntitySourceKey,
1964) -> Result<&'a icydb_schema::EntityFragment, InternalError> {
1965 proposal
1966 .fragments()
1967 .iter()
1968 .flat_map(icydb_schema::SchemaFragment::entities)
1969 .find(|entity| entity.source_key() == source)
1970 .ok_or_else(InternalError::store_invariant)
1971}
1972
1973#[cfg(feature = "migration")]
1974fn current_proposal_lineage_is_applied<C: CanisterKind>(
1975 db: &Db<C>,
1976 proposal: &SchemaProposal,
1977 accepted_head: &ExpectedAcceptedHead,
1978) -> Result<bool, InternalError> {
1979 let Some(lineage) = load_entity_source_lineage_catalog()? else {
1980 return Ok(false);
1981 };
1982 let entities = proposal
1983 .fragments()
1984 .iter()
1985 .flat_map(icydb_schema::SchemaFragment::entities)
1986 .collect::<Vec<_>>();
1987 if entities.len() != lineage.entries().len() {
1988 return Ok(false);
1989 }
1990 let target = proposal.target_database();
1991 let authorities = application_authorities(db);
1992 for entity in entities {
1993 let source = entity.source_key();
1994 let digest = proposal
1995 .entity_source_digest(source)
1996 .map_err(|_| InternalError::store_invariant())?;
1997 let mut matched = false;
1998 for authority in &authorities {
1999 let store_identity = derive_store_identity(target, authority);
2000 let entity_tag = authority
2001 .handle
2002 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
2003 .and_then(|bundle| bundle.source_bindings().entity(source));
2004 let Some(entity_tag) = entity_tag else {
2005 continue;
2006 };
2007 let Some(entry) = lineage.get(store_identity, entity_tag) else {
2008 return Ok(false);
2009 };
2010 matched = entry.accepted_head() == accepted_head
2011 && matches!(
2012 entry.state(),
2013 AcceptedEntitySourceLineageState::Adopted { version, source_digest }
2014 if version.get() == entity.version().get() && *source_digest == digest
2015 );
2016 break;
2017 }
2018 if !matched {
2019 return Ok(false);
2020 }
2021 }
2022 Ok(true)
2023}
2024
2025#[cfg(feature = "migration")]
2026fn preflight_unpublished_schema_migration<C: CanisterKind>(
2027 target: &SchemaApplicationTarget,
2028 proposal: &SchemaProposal,
2029 db: &Db<C>,
2030) -> Result<(), InternalError> {
2031 let authorities = application_authorities(db);
2032 let current_bundles = authorities
2033 .iter()
2034 .map(|authority| {
2035 authority
2036 .handle
2037 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
2038 })
2039 .collect::<Result<Vec<_>, InternalError>>()?;
2040 let stores = authorities
2041 .iter()
2042 .zip(¤t_bundles)
2043 .filter_map(|(authority, bundle)| {
2044 bundle.as_ref().map(|bundle| ExistingProposalStore {
2045 path: authority.path,
2046 identity: derive_store_identity(target.database_identity(), authority),
2047 bundle,
2048 })
2049 })
2050 .collect::<Vec<_>>();
2051 let lineage = load_entity_source_lineage_catalog()?.unwrap_or_default();
2052 let planned = plan_schema_migration(proposal, stores.as_slice(), &lineage)
2053 .map_err(schema_migration_planning_error)?;
2054 if planned.candidates().is_empty() || planned.lineage().is_empty() {
2055 return Err(InternalError::store_invariant());
2056 }
2057 for next in planned.lineage() {
2058 let current = lineage
2059 .get(next.store(), next.entity())
2060 .ok_or_else(InternalError::store_invariant)?;
2061 let AcceptedEntitySourceLineageState::Adopted {
2062 version,
2063 source_digest,
2064 } = current.state()
2065 else {
2066 return Err(InternalError::store_invariant());
2067 };
2068 let expected_version = version
2069 .get()
2070 .checked_add(1)
2071 .ok_or_else(InternalError::store_invariant)?;
2072 if next.version().get() != expected_version || next.digest() == *source_digest {
2073 return Err(InternalError::store_invariant());
2074 }
2075 }
2076 Ok(())
2077}
2078
2079fn lower_application_candidates(
2080 target: &SchemaApplicationTarget,
2081 proposal: &SchemaProposal,
2082 authorities: &[StoreApplicationAuthority],
2083) -> Result<LoweredApplication, InternalError> {
2084 let current_bundles = authorities
2085 .iter()
2086 .map(|authority| {
2087 authority
2088 .handle
2089 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)
2090 })
2091 .collect::<Result<Vec<_>, InternalError>>()?;
2092 let initial_application = matches!(target.accepted_head(), ExpectedAcceptedHead::Empty);
2093 let mut candidates = match target.accepted_head() {
2094 ExpectedAcceptedHead::Empty => {
2095 let stores = authorities
2096 .iter()
2097 .map(|authority| ProposalStoreTarget {
2098 path: authority.path,
2099 identity: derive_store_identity(target.database_identity(), authority),
2100 })
2101 .collect::<Vec<_>>();
2102 let candidates = lower_initial_schema_proposal(proposal, stores.as_slice())?;
2103 #[cfg(feature = "migration")]
2104 {
2105 let planned = plan_initial_entity_source_lineage(proposal, &candidates)
2106 .map_err(schema_migration_planning_error)?;
2107 if planned.len()
2108 != proposal
2109 .fragments()
2110 .iter()
2111 .map(|fragment| fragment.entities().len())
2112 .sum::<usize>()
2113 {
2114 return Err(InternalError::store_invariant());
2115 }
2116 }
2117 candidates
2118 }
2119 ExpectedAcceptedHead::Exact { .. }
2120 if proposal.fragments().is_empty() && proposal.removals().is_empty() =>
2121 {
2122 Vec::new()
2123 }
2124 ExpectedAcceptedHead::Exact { .. } => {
2125 let stores = authorities
2126 .iter()
2127 .zip(¤t_bundles)
2128 .filter_map(|(authority, bundle)| {
2129 bundle.as_ref().map(|bundle| ExistingProposalStore {
2130 path: authority.path,
2131 identity: derive_store_identity(target.database_identity(), authority),
2132 bundle,
2133 })
2134 })
2135 .collect::<Vec<_>>();
2136 lower_existing_schema_proposal(proposal, stores.as_slice())?
2137 }
2138 };
2139 let pending = if initial_application {
2140 preflight_initial_application(authorities, &candidates)?;
2141 None
2142 } else {
2143 preflight_existing_application(authorities, ¤t_bundles, &mut candidates)?
2144 };
2145 Ok(LoweredApplication {
2146 current_bundles,
2147 candidates,
2148 pending,
2149 })
2150}
2151
2152fn validate_database_identity_state_capacity(
2153 authorities: &[StoreApplicationAuthority],
2154 candidates: &[CandidateSchemaRevision],
2155 incarnation: crate::db::integrity::DatabaseIncarnationId,
2156) -> Result<(), InternalError> {
2157 let mut total = 0usize;
2158 for authority in authorities {
2159 let count = match candidates
2160 .iter()
2161 .find(|candidate| candidate.store_path() == authority.path)
2162 {
2163 Some(candidate) => authority.handle.with_schema(|store| {
2164 store.projected_identity_state_count(incarnation, candidate)
2165 })?,
2166 None => authority
2167 .handle
2168 .with_schema(|store| store.identity_state_inventory_for_integrity(incarnation))?
2169 .len(),
2170 };
2171 total = include_identity_state_count(total, count)?;
2172 }
2173 Ok(())
2174}
2175
2176fn include_identity_state_count(total: usize, count: usize) -> Result<usize, InternalError> {
2177 let total = total
2178 .checked_add(count)
2179 .ok_or_else(InternalError::identity_state_capacity_exhausted)?;
2180 if total > MAX_IDENTITY_STATE_RECORDS_PER_DATABASE {
2181 return Err(InternalError::identity_state_capacity_exhausted());
2182 }
2183 Ok(total)
2184}
2185
2186fn preflight_initial_application(
2187 authorities: &[StoreApplicationAuthority],
2188 candidates: &[crate::db::schema::CandidateSchemaRevision],
2189) -> Result<(), InternalError> {
2190 for candidate in candidates {
2191 let authority = authorities
2192 .iter()
2193 .find(|authority| authority.path == candidate.store_path())
2194 .ok_or_else(InternalError::store_invariant)?;
2195 if authority.handle.with_data(DataStore::len) != 0
2196 || authority.handle.index_state() != IndexState::Ready
2197 || !authority.handle.with_index(IndexStore::is_empty)
2198 {
2199 return Err(InternalError::store_unsupported());
2200 }
2201 }
2202 Ok(())
2203}
2204
2205fn preflight_existing_application(
2212 authorities: &[StoreApplicationAuthority],
2213 current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
2214 candidates: &mut [CandidateSchemaRevision],
2215) -> Result<Option<PendingGeneratedRowLocalConstraint>, InternalError> {
2216 require_empty_physical_entity_removal(authorities, current_bundles, candidates)?;
2217 require_empty_physical_field_removals(authorities, current_bundles, candidates)?;
2218 require_empty_physical_index_removals(authorities, current_bundles, candidates)?;
2219 require_empty_physical_relation_removals(authorities, current_bundles, candidates)?;
2220 let proofs = generated_row_local_constraint_proofs(authorities, current_bundles, candidates)?;
2221 if proofs
2222 .iter()
2223 .filter(|proof| proof.historical_rows != 0)
2224 .count()
2225 > 1
2226 {
2227 return Err(InternalError::store_unsupported());
2228 }
2229
2230 let mut pending = None;
2231 for candidate_index in 0..candidates.len() {
2232 let candidate = candidates
2233 .get(candidate_index)
2234 .cloned()
2235 .ok_or_else(InternalError::store_invariant)?;
2236 let candidate_proofs = proofs
2237 .iter()
2238 .filter(|proof| proof.candidate_index == candidate_index)
2239 .collect::<Vec<_>>();
2240 if candidate_proofs.is_empty() {
2241 continue;
2242 }
2243
2244 let mut snapshots = candidate.bundle().entity_snapshots().clone();
2245 for proof in candidate_proofs {
2246 let mut promote = true;
2247 if proof.historical_rows != 0 {
2248 match validate_unpublished_row_local_candidate_bounded(
2249 proof.store,
2250 proof.store_path,
2251 proof.entity_tag,
2252 proof.entity_path.as_str(),
2253 &candidate,
2254 proof.constraint_id,
2255 )? {
2256 UnpublishedRowLocalValidation::Complete { .. } => {}
2257 UnpublishedRowLocalValidation::Incomplete => {
2258 if proof.store.storage_capabilities().recovery()
2259 != StoreRecoveryCapability::StableBasePlusJournalReplay
2260 || pending.is_some()
2261 {
2262 return Err(InternalError::store_unsupported());
2263 }
2264 pending = Some(PendingGeneratedRowLocalConstraint {
2265 proof: (*proof).clone(),
2266 });
2267 promote = false;
2268 }
2269 }
2270 }
2271 if !promote {
2272 continue;
2273 }
2274 let snapshot = snapshots
2275 .get(&proof.entity_tag)
2276 .cloned()
2277 .ok_or_else(InternalError::store_invariant)?;
2278 let catalog = snapshot
2279 .constraint_catalog()
2280 .clone()
2281 .with_directly_validated_activation(proof.constraint_id)
2282 .map_err(|_| InternalError::store_invariant())?;
2283 snapshots.insert(proof.entity_tag, snapshot.with_constraint_catalog(catalog));
2284 }
2285 let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
2286 candidate.revision(),
2287 candidate.bundle().store_path(),
2288 candidate.bundle().enum_catalog().clone(),
2289 candidate.bundle().composite_catalog().clone(),
2290 candidate.bundle().source_bindings().clone(),
2291 snapshots,
2292 )?;
2293 candidates[candidate_index] = CandidateSchemaRevision::new(bundle)?;
2294 }
2295 Ok(pending)
2296}
2297
2298fn require_empty_physical_entity_removal(
2305 authorities: &[StoreApplicationAuthority],
2306 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2307 candidates: &[CandidateSchemaRevision],
2308) -> Result<(), InternalError> {
2309 let mut removed_entity = None;
2310 for candidate in candidates {
2311 let (position, source_authority) = authorities
2312 .iter()
2313 .enumerate()
2314 .find(|(_, authority)| authority.path == candidate.store_path())
2315 .ok_or_else(InternalError::store_invariant)?;
2316 let current = current_bundles
2317 .get(position)
2318 .and_then(Option::as_ref)
2319 .ok_or_else(InternalError::store_invariant)?;
2320 let removed = current
2321 .entity_snapshots()
2322 .iter()
2323 .filter(|(entity_tag, _)| {
2324 !candidate
2325 .bundle()
2326 .entity_snapshots()
2327 .contains_key(entity_tag)
2328 })
2329 .collect::<Vec<_>>();
2330 if removed.is_empty() {
2331 continue;
2332 }
2333 let [(entity_tag, snapshot)] = removed.as_slice() else {
2334 return Err(InternalError::store_unsupported());
2335 };
2336 let entity_tag = **entity_tag;
2337 let snapshot = *snapshot;
2338 if removed_entity.is_some()
2339 || current.entity_snapshots().len()
2340 != candidate
2341 .bundle()
2342 .entity_snapshots()
2343 .len()
2344 .saturating_add(1)
2345 {
2346 return Err(InternalError::store_unsupported());
2347 }
2348 require_exact_empty_entity(source_authority.handle, entity_tag)?;
2349 source_authority
2350 .handle
2351 .with_index(|store| prove_empty_user_index_domain(store, entity_tag))
2352 .map_err(StagedUserIndexDomainError::into_internal_error)?;
2353 for relation in snapshot.relations() {
2354 let target_store = accepted_entity_store_for_path(
2355 authorities,
2356 current_bundles,
2357 relation.target_path(),
2358 )?;
2359 target_store.with_index(|store| {
2360 prove_empty_reverse_relation_domain(store, entity_tag, snapshot, relation)
2361 })?;
2362 }
2363 removed_entity = Some(snapshot.entity_path());
2364 }
2365
2366 let Some(removed_path) = removed_entity else {
2367 return Ok(());
2368 };
2369 for (position, authority) in authorities.iter().enumerate() {
2370 let after = candidates
2371 .iter()
2372 .find(|candidate| candidate.store_path() == authority.path)
2373 .map(CandidateSchemaRevision::bundle)
2374 .or_else(|| current_bundles.get(position).and_then(Option::as_ref));
2375 let Some(after) = after else {
2376 continue;
2377 };
2378 if after
2379 .entity_snapshots()
2380 .values()
2381 .flat_map(crate::db::schema::PersistedSchemaSnapshot::relations)
2382 .any(|relation| relation.target_path() == removed_path)
2383 {
2384 return Err(InternalError::store_unsupported());
2385 }
2386 }
2387 Ok(())
2388}
2389
2390fn require_empty_physical_relation_removals(
2393 authorities: &[StoreApplicationAuthority],
2394 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2395 candidates: &[CandidateSchemaRevision],
2396) -> Result<(), InternalError> {
2397 for candidate in candidates {
2398 let (position, source_authority) = authorities
2399 .iter()
2400 .enumerate()
2401 .find(|(_, authority)| authority.path == candidate.store_path())
2402 .ok_or_else(InternalError::store_invariant)?;
2403 let current = current_bundles
2404 .get(position)
2405 .and_then(Option::as_ref)
2406 .ok_or_else(InternalError::store_invariant)?;
2407 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2408 let before = current
2409 .entity_snapshots()
2410 .get(entity_tag)
2411 .ok_or_else(InternalError::store_invariant)?;
2412 let removed = before
2413 .relations()
2414 .iter()
2415 .filter(|relation| {
2416 !after
2417 .relations()
2418 .iter()
2419 .any(|candidate| candidate.id() == relation.id())
2420 })
2421 .collect::<Vec<_>>();
2422 if removed.is_empty() {
2423 continue;
2424 }
2425 let added = after.relations().iter().any(|relation| {
2426 !before
2427 .relations()
2428 .iter()
2429 .any(|accepted| accepted.id() == relation.id())
2430 });
2431 let [removed] = removed.as_slice() else {
2432 return Err(InternalError::store_unsupported());
2433 };
2434 if added || before.relations().len() != after.relations().len().saturating_add(1) {
2435 return Err(InternalError::store_unsupported());
2436 }
2437 require_exact_empty_entity(source_authority.handle, *entity_tag)?;
2438 let target_store = accepted_entity_store_for_path(
2439 authorities,
2440 current_bundles,
2441 removed.target_path(),
2442 )?;
2443 target_store.with_index(|store| {
2444 prove_empty_reverse_relation_domain(store, *entity_tag, before, removed)
2445 })?;
2446 }
2447 }
2448 Ok(())
2449}
2450
2451fn accepted_entity_store_for_path(
2452 authorities: &[StoreApplicationAuthority],
2453 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2454 entity_path: &str,
2455) -> Result<StoreHandle, InternalError> {
2456 let mut resolved = None;
2457 for (position, bundle) in current_bundles.iter().enumerate() {
2458 let Some(bundle) = bundle else {
2459 continue;
2460 };
2461 if !bundle
2462 .entity_snapshots()
2463 .values()
2464 .any(|snapshot| snapshot.entity_path() == entity_path)
2465 {
2466 continue;
2467 }
2468 if resolved.is_some() {
2469 return Err(InternalError::store_invariant());
2470 }
2471 resolved = authorities.get(position).map(|authority| authority.handle);
2472 }
2473 resolved.ok_or_else(InternalError::store_unsupported)
2474}
2475
2476fn require_empty_physical_index_removals(
2480 authorities: &[StoreApplicationAuthority],
2481 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2482 candidates: &[CandidateSchemaRevision],
2483) -> Result<(), InternalError> {
2484 for candidate in candidates {
2485 let (position, authority) = authorities
2486 .iter()
2487 .enumerate()
2488 .find(|(_, authority)| authority.path == candidate.store_path())
2489 .ok_or_else(InternalError::store_invariant)?;
2490 let current = current_bundles
2491 .get(position)
2492 .and_then(Option::as_ref)
2493 .ok_or_else(InternalError::store_invariant)?;
2494 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2495 let before = current
2496 .entity_snapshots()
2497 .get(entity_tag)
2498 .ok_or_else(InternalError::store_invariant)?;
2499 if before.indexes().len() == after.indexes().len() {
2500 continue;
2501 }
2502 if before.indexes().len() != after.indexes().len().saturating_add(1) {
2503 return Err(InternalError::store_unsupported());
2504 }
2505 require_exact_empty_entity(authority.handle, *entity_tag)?;
2506 authority
2507 .handle
2508 .with_index(|store| prove_empty_user_index_domain(store, *entity_tag))
2509 .map_err(StagedUserIndexDomainError::into_internal_error)?;
2510 }
2511 }
2512 Ok(())
2513}
2514
2515fn require_empty_physical_field_removals(
2518 authorities: &[StoreApplicationAuthority],
2519 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2520 candidates: &[CandidateSchemaRevision],
2521) -> Result<(), InternalError> {
2522 for candidate in candidates {
2523 let (position, authority) = authorities
2524 .iter()
2525 .enumerate()
2526 .find(|(_, authority)| authority.path == candidate.store_path())
2527 .ok_or_else(InternalError::store_invariant)?;
2528 let current = current_bundles
2529 .get(position)
2530 .and_then(Option::as_ref)
2531 .ok_or_else(InternalError::store_invariant)?;
2532 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2533 let before = current
2534 .entity_snapshots()
2535 .get(entity_tag)
2536 .ok_or_else(InternalError::store_invariant)?;
2537 if before.row_layout() == after.row_layout() {
2538 continue;
2539 }
2540 if before.fields().len() != after.fields().len().saturating_add(1) {
2541 return Err(InternalError::store_unsupported());
2542 }
2543 require_exact_empty_entity(authority.handle, *entity_tag)?;
2544 }
2545 }
2546 Ok(())
2547}
2548
2549fn require_exact_empty_entity(
2553 store: StoreHandle,
2554 entity_tag: EntityTag,
2555) -> Result<(), InternalError> {
2556 require_exact_empty_entity_count(store.with_data(|data| data.exact_entity_count(entity_tag)))
2557}
2558
2559fn require_exact_empty_entity_count(count: Option<u64>) -> Result<(), InternalError> {
2560 let count = count.ok_or_else(InternalError::store_corruption)?;
2561 if count != 0 {
2562 return Err(InternalError::store_unsupported());
2563 }
2564
2565 Ok(())
2566}
2567
2568fn generated_row_local_constraint_proofs(
2569 authorities: &[StoreApplicationAuthority],
2570 current_bundles: &[Option<AcceptedSchemaRevisionBundle>],
2571 candidates: &[CandidateSchemaRevision],
2572) -> Result<Vec<DirectGeneratedRowLocalProof>, InternalError> {
2573 let mut proofs = Vec::new();
2574 for (candidate_index, candidate) in candidates.iter().enumerate() {
2575 let (position, authority) = authorities
2576 .iter()
2577 .enumerate()
2578 .find(|(_, authority)| authority.path == candidate.store_path())
2579 .ok_or_else(InternalError::store_invariant)?;
2580 let current = current_bundles
2581 .get(position)
2582 .and_then(Option::as_ref)
2583 .ok_or_else(InternalError::store_invariant)?;
2584 for (entity_tag, after) in candidate.bundle().entity_snapshots() {
2585 let before = current
2586 .entity_snapshots()
2587 .get(entity_tag)
2588 .ok_or_else(InternalError::store_invariant)?;
2589 for constraint_id in added_generated_row_local_activations(before, after) {
2590 let historical_rows = authority
2591 .handle
2592 .with_data(|store| store.exact_entity_count(*entity_tag))
2593 .ok_or_else(InternalError::store_corruption)?;
2594 proofs.push(DirectGeneratedRowLocalProof {
2595 candidate_index,
2596 store: authority.handle,
2597 store_path: authority.path,
2598 entity_tag: *entity_tag,
2599 entity_path: after.entity_path().to_string(),
2600 constraint_id,
2601 historical_rows,
2602 });
2603 }
2604 }
2605 }
2606 Ok(proofs)
2607}
2608
2609fn added_generated_row_local_activations(
2610 before: &crate::db::schema::PersistedSchemaSnapshot,
2611 after: &crate::db::schema::PersistedSchemaSnapshot,
2612) -> Vec<ConstraintId> {
2613 after
2614 .constraint_activations()
2615 .iter()
2616 .filter(|candidate| {
2617 candidate.origin() == ConstraintOrigin::Generated
2618 && matches!(
2619 candidate.kind(),
2620 ConstraintActivationKind::Check { .. }
2621 | ConstraintActivationKind::TargetedRule { .. }
2622 )
2623 && !before
2624 .constraint_activations()
2625 .iter()
2626 .any(|accepted| accepted.id() == candidate.id())
2627 })
2628 .map(crate::db::schema::ConstraintActivationSnapshot::id)
2629 .collect()
2630}
2631
2632fn final_candidates_for_pending_row_local_constraint(
2633 candidates: &[CandidateSchemaRevision],
2634 pending: &PendingGeneratedRowLocalConstraint,
2635) -> Result<Vec<CandidateSchemaRevision>, InternalError> {
2636 let mut final_candidates = candidates.to_vec();
2637 let candidate = final_candidates
2638 .get(pending.proof.candidate_index)
2639 .cloned()
2640 .ok_or_else(InternalError::store_invariant)?;
2641 if candidate.store_path() != pending.proof.store_path {
2642 return Err(InternalError::store_invariant());
2643 }
2644 let mut snapshots = candidate.bundle().entity_snapshots().clone();
2645 let snapshot = snapshots
2646 .get(&pending.proof.entity_tag)
2647 .cloned()
2648 .ok_or_else(InternalError::store_invariant)?;
2649 let catalog = snapshot
2650 .constraint_catalog()
2651 .clone()
2652 .with_directly_validated_activation(pending.proof.constraint_id)
2653 .map_err(|_| InternalError::store_invariant())?;
2654 snapshots.insert(
2655 pending.proof.entity_tag,
2656 snapshot.with_constraint_catalog(catalog),
2657 );
2658 let final_revision = candidate
2659 .revision()
2660 .checked_next()
2661 .and_then(AcceptedSchemaRevision::checked_next)
2662 .ok_or_else(InternalError::store_unsupported)?;
2663 let bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
2664 final_revision,
2665 candidate.bundle().store_path(),
2666 candidate.bundle().enum_catalog().clone(),
2667 candidate.bundle().composite_catalog().clone(),
2668 candidate.bundle().source_bindings().clone(),
2669 snapshots,
2670 )?;
2671 final_candidates[pending.proof.candidate_index] = CandidateSchemaRevision::new(bundle)?;
2672 Ok(final_candidates)
2673}
2674
2675fn schema_change_progress_status(
2676 snapshot: &crate::db::schema::PersistedSchemaSnapshot,
2677 entity_tag: EntityTag,
2678 constraint_id: ConstraintId,
2679 progress: ConstraintValidationProgress,
2680) -> Result<SchemaChangeProgressStatus, InternalError> {
2681 match progress {
2682 ConstraintValidationProgress::Started => Ok(SchemaChangeProgressStatus::Started),
2683 ConstraintValidationProgress::Advanced {
2684 phase,
2685 rows_scanned,
2686 } => Ok(SchemaChangeProgressStatus::Advanced {
2687 phase: schema_change_validation_phase(phase),
2688 rows_scanned,
2689 }),
2690 ConstraintValidationProgress::Findings {
2691 receipt,
2692 phase,
2693 rows_scanned,
2694 } => {
2695 let activation = snapshot
2696 .constraint_catalog()
2697 .activation(constraint_id)
2698 .ok_or_else(InternalError::store_corruption)?;
2699 let fingerprint =
2700 crate::db::schema::accepted_schema_cache_fingerprint_for_persisted_snapshot(
2701 snapshot,
2702 )?;
2703 let findings = receipt
2704 .findings()
2705 .iter()
2706 .map(|finding| {
2707 constraint_validation_finding_output(
2708 fingerprint,
2709 entity_tag,
2710 activation,
2711 finding,
2712 )
2713 })
2714 .collect::<Result<Vec<_>, InternalError>>()?;
2715 Ok(SchemaChangeProgressStatus::Findings {
2716 phase: schema_change_validation_phase(phase),
2717 rows_scanned,
2718 page_sequence: receipt.page_sequence(),
2719 findings,
2720 })
2721 }
2722 ConstraintValidationProgress::Restarted { rows_scanned } => {
2723 Ok(SchemaChangeProgressStatus::Restarted { rows_scanned })
2724 }
2725 ConstraintValidationProgress::Promoted { .. } => Ok(SchemaChangeProgressStatus::Applied),
2726 }
2727}
2728
2729const fn schema_change_validation_phase(
2730 phase: ConstraintValidationPhase,
2731) -> SchemaChangeValidationPhase {
2732 match phase {
2733 ConstraintValidationPhase::Forward => SchemaChangeValidationPhase::Forward,
2734 ConstraintValidationPhase::Verify => SchemaChangeValidationPhase::Verify,
2735 }
2736}
2737
2738fn finalize_schema_application<C: CanisterKind>(
2739 db: &Db<C>,
2740 record: &SchemaApplicationRecord,
2741 candidate_head: &ExpectedAcceptedHead,
2742 status: SchemaChangeProgressStatus,
2743) -> Result<SchemaChangeProgress, InternalError> {
2744 if schema_application_target(db)?.accepted_head() != candidate_head {
2745 return Err(InternalError::schema_application_conflict());
2746 }
2747 let receipt = SchemaChangeReceipt::new(
2748 record.receipt().database_identity(),
2749 record.receipt().submission_key().clone(),
2750 record.receipt().proposal_digest(),
2751 record.receipt().prior_head().clone(),
2752 SchemaChangeOutcome::Applied {
2753 accepted_head: candidate_head.clone(),
2754 },
2755 )?;
2756 let terminal = SchemaApplicationRecord::new(receipt.clone(), Vec::new())?;
2757 let operation = SchemaApplicationRecordOp::replace(record, &terminal)?;
2758 publish_accepted_schema_candidates_with_application_record(Vec::new(), operation)?;
2759 Ok(SchemaChangeProgress::new(receipt, status))
2760}
2761
2762fn application_publications<'a>(
2763 authorities: &[StoreApplicationAuthority],
2764 current_bundles: &[Option<crate::db::schema::AcceptedSchemaRevisionBundle>],
2765 candidates: &'a [crate::db::schema::CandidateSchemaRevision],
2766) -> Result<Vec<AcceptedSchemaPublication<'a>>, InternalError> {
2767 candidates
2768 .iter()
2769 .map(|candidate| {
2770 let (position, authority) = authorities
2771 .iter()
2772 .enumerate()
2773 .find(|(_, authority)| authority.path == candidate.store_path())
2774 .ok_or_else(InternalError::store_invariant)?;
2775 let expected_revision = current_bundles[position].as_ref().map_or(
2776 AcceptedSchemaRevision::NONE,
2777 crate::db::schema::AcceptedSchemaRevisionBundle::revision,
2778 );
2779 Ok(AcceptedSchemaPublication::new(
2780 authority.path,
2781 authority.handle,
2782 expected_revision,
2783 candidate,
2784 ))
2785 })
2786 .collect()
2787}
2788
2789fn application_authorities<C: CanisterKind>(db: &Db<C>) -> Vec<StoreApplicationAuthority> {
2790 let mut authorities = db.with_store_registry(|registry| {
2791 registry
2792 .iter()
2793 .map(|(path, handle)| StoreApplicationAuthority { path, handle })
2794 .collect::<Vec<_>>()
2795 });
2796 authorities.sort_unstable_by(|left, right| left.path.cmp(right.path));
2797 authorities
2798}
2799
2800fn accepted_head_after_candidates(
2801 authorities: &[StoreApplicationAuthority],
2802 candidates: &[crate::db::schema::CandidateSchemaRevision],
2803) -> Result<ExpectedAcceptedHead, InternalError> {
2804 let heads = authorities
2805 .iter()
2806 .map(|authority| {
2807 let candidate = candidates
2808 .iter()
2809 .find(|candidate| candidate.store_path() == authority.path);
2810 let head = match candidate {
2811 Some(candidate) => Some(AcceptedStoreHead {
2812 revision: candidate.revision().get(),
2813 fingerprint: candidate.root().fingerprint().as_bytes(),
2814 }),
2815 None => authority
2816 .handle
2817 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
2818 .map(|selection| AcceptedStoreHead {
2819 revision: selection.root().revision().get(),
2820 fingerprint: selection.root().fingerprint().as_bytes(),
2821 }),
2822 };
2823 Ok((authority.path, head))
2824 })
2825 .collect::<Result<Vec<_>, InternalError>>()?;
2826 Ok(derive_accepted_head(heads.as_slice()))
2827}
2828
2829fn derive_database_identity(
2830 incarnation: [u8; 16],
2831 stores: &[StoreApplicationAuthority],
2832) -> TargetDatabaseIdentity {
2833 let mut hasher = new_hash_sha256_prefixed(DATABASE_TARGET_FINGERPRINT_PROFILE);
2834 hasher.update(incarnation);
2835 write_hash_len_u32(&mut hasher, stores.len());
2836 for store in stores {
2837 write_store_authority(&mut hasher, store);
2838 }
2839 TargetDatabaseIdentity::from_bytes(finalize_hash_sha256(hasher))
2840}
2841
2842fn derive_store_identity(
2843 database_identity: TargetDatabaseIdentity,
2844 store: &StoreApplicationAuthority,
2845) -> TargetStoreIdentity {
2846 let mut hasher = new_hash_sha256_prefixed(STORE_TARGET_FINGERPRINT_PROFILE);
2847 hasher.update(database_identity.to_bytes());
2848 write_store_authority(&mut hasher, store);
2849 TargetStoreIdentity::from_bytes(finalize_hash_sha256(hasher))
2850}
2851
2852fn derive_accepted_head(stores: &[(&str, Option<AcceptedStoreHead>)]) -> ExpectedAcceptedHead {
2853 let Some(revision) = stores
2854 .iter()
2855 .filter_map(|(_, head)| head.map(|head| head.revision))
2856 .max()
2857 else {
2858 return ExpectedAcceptedHead::Empty;
2859 };
2860
2861 let mut hasher = new_hash_sha256_prefixed(ACCEPTED_DATABASE_HEAD_FINGERPRINT_PROFILE);
2862 write_hash_len_u32(&mut hasher, stores.len());
2863 for (path, head) in stores {
2864 write_hash_str_u32(&mut hasher, path);
2865 match head {
2866 None => write_hash_tag_u8(&mut hasher, 0),
2867 Some(head) => {
2868 write_hash_tag_u8(&mut hasher, 1);
2869 write_hash_u64(&mut hasher, head.revision);
2870 hasher.update(head.fingerprint);
2871 }
2872 }
2873 }
2874
2875 ExpectedAcceptedHead::Exact {
2876 revision,
2877 fingerprint: ExpectedSchemaFingerprint::from_bytes(finalize_hash_sha256(hasher)),
2878 }
2879}
2880
2881pub(in crate::db) fn generated_schema_reconciled(
2882 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
2883 incarnation: DatabaseIncarnationId,
2884 submission_key: &str,
2885) -> Result<(bool, ExpectedAcceptedHead), InternalError> {
2886 let submission_key = SchemaSubmissionKey::try_new(submission_key.to_string())
2887 .map_err(|_| InternalError::store_invariant())?;
2888 let (database_identity, accepted_head) = generated_schema_authority(registry, incarnation)?;
2889 let reconciled = load_schema_application_record_read_only(database_identity, &submission_key)?
2890 .is_some_and(|record| match record.receipt().outcome() {
2891 SchemaChangeOutcome::NoOp { .. } | SchemaChangeOutcome::Applied { .. } => true,
2899 SchemaChangeOutcome::Pending { .. } | SchemaChangeOutcome::Aborted { .. } => false,
2900 });
2901 Ok((reconciled, accepted_head))
2902}
2903
2904pub(in crate::db) fn generated_schema_authority(
2905 registry: &'static std::thread::LocalKey<crate::db::registry::StoreRegistry>,
2906 incarnation: DatabaseIncarnationId,
2907) -> Result<(TargetDatabaseIdentity, ExpectedAcceptedHead), InternalError> {
2908 let mut stores = registry.with(|registry| {
2909 registry
2910 .iter()
2911 .map(|(path, handle)| StoreApplicationAuthority { path, handle })
2912 .collect::<Vec<_>>()
2913 });
2914 stores.sort_unstable_by(|left, right| left.path.cmp(right.path));
2915 let database_identity = derive_database_identity(incarnation.to_bytes(), stores.as_slice());
2916 let heads = stores
2917 .iter()
2918 .map(|store| {
2919 let head = store
2920 .handle
2921 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_root)?
2922 .map(|selection| AcceptedStoreHead {
2923 revision: selection.root().revision().get(),
2924 fingerprint: selection.root().fingerprint().as_bytes(),
2925 });
2926 Ok((store.path, head))
2927 })
2928 .collect::<Result<Vec<_>, InternalError>>()?;
2929 let accepted_head = derive_accepted_head(heads.as_slice());
2930 Ok((database_identity, accepted_head))
2931}
2932
2933fn write_store_authority(hasher: &mut sha2::Sha256, store: &StoreApplicationAuthority) {
2934 write_hash_str_u32(hasher, store.path);
2935 write_storage_capabilities(hasher, store.handle);
2936 for allocation in [
2937 store.handle.data_allocation(),
2938 store.handle.index_allocation(),
2939 store.handle.schema_allocation(),
2940 store.handle.journal_allocation(),
2941 ] {
2942 write_allocation_identity(hasher, allocation);
2943 }
2944}
2945
2946fn write_storage_capabilities(hasher: &mut sha2::Sha256, store: StoreHandle) {
2947 let capabilities = store.storage_capabilities();
2948 write_hash_tag_u8(
2949 hasher,
2950 match capabilities.storage_mode() {
2951 StoreRuntimeStorageMode::Heap => 0,
2952 StoreRuntimeStorageMode::Journaled => 1,
2953 },
2954 );
2955 write_hash_tag_u8(
2956 hasher,
2957 match capabilities.allocation_identity() {
2958 StoreAllocationIdentityCapability::Present => 0,
2959 StoreAllocationIdentityCapability::Absent => 1,
2960 },
2961 );
2962 write_hash_tag_u8(
2963 hasher,
2964 match capabilities.durability() {
2965 StoreDurability::Durable => 0,
2966 StoreDurability::Volatile => 1,
2967 },
2968 );
2969 write_hash_tag_u8(
2970 hasher,
2971 match capabilities.recovery() {
2972 StoreRecoveryCapability::StableBasePlusJournalReplay => 0,
2973 StoreRecoveryCapability::None => 1,
2974 },
2975 );
2976 write_hash_tag_u8(
2977 hasher,
2978 match capabilities.commit_participation() {
2979 StoreCommitParticipation::Durable => 0,
2980 StoreCommitParticipation::LiveOnly => 1,
2981 },
2982 );
2983 write_hash_tag_u8(
2984 hasher,
2985 match capabilities.schema_metadata() {
2986 StoreSchemaMetadataCapability::LiveRebuiltMetadata => 0,
2987 StoreSchemaMetadataCapability::CanonicalStableHistoryPlusJournalTail => 1,
2988 },
2989 );
2990 write_hash_tag_u8(
2991 hasher,
2992 match capabilities.relation_source() {
2993 StoreRelationSourceCapability::DurableSource => 0,
2994 StoreRelationSourceCapability::LiveSource => 1,
2995 },
2996 );
2997 write_hash_tag_u8(
2998 hasher,
2999 match capabilities.relation_target() {
3000 StoreRelationTargetCapability::DurableTarget => 0,
3001 StoreRelationTargetCapability::VolatileTarget => 1,
3002 },
3003 );
3004}
3005
3006fn write_allocation_identity(
3007 hasher: &mut sha2::Sha256,
3008 allocation: Option<StoreAllocationIdentity>,
3009) {
3010 match allocation {
3011 None => write_hash_tag_u8(hasher, 0),
3012 Some(allocation) => {
3013 write_hash_tag_u8(hasher, 1);
3014 write_hash_tag_u8(hasher, allocation.memory_id());
3015 write_hash_str_u32(hasher, allocation.stable_key());
3016 }
3017 }
3018}
3019
3020#[cfg(test)]
3021mod tests {
3022 use super::{
3023 AcceptedSchemaPublication, AcceptedStoreHead, DirectGeneratedRowLocalProof,
3024 PendingGeneratedRowLocalConstraint, abort_schema_application,
3025 aborted_generated_row_local_candidate, accepted_head_after_candidates,
3026 application_authorities, apply_schema, continue_schema_application, derive_accepted_head,
3027 derive_schema_change_job_id, final_candidates_for_pending_row_local_constraint,
3028 include_identity_state_count, lower_existing_schema_proposal,
3029 lower_initial_schema_proposal, publish_accepted_schema_candidates_with_application_record,
3030 require_exact_empty_entity_count, schema_application_target,
3031 };
3032 use crate::{
3033 db::{
3034 DatabaseStartupState, Db, GeneratedStartupDriverStep,
3035 commit::{RecoveryProgress, continue_recovery, forget_recovered_domain_for_tests},
3036 data::DataStore,
3037 drive_generated_startup_recovery_page,
3038 index::IndexStore,
3039 journal::JournalTailStore,
3040 observe_generated_startup_state,
3041 registry::{
3042 StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
3043 StoreRuntimeStorageCapabilities,
3044 },
3045 schema::{
3046 AcceptedConstraintKind, AcceptedRuleOperation, AcceptedSchemaRevisionBundle,
3047 CandidateSchemaRevision, ConstraintOrigin, ConstraintValidationJob,
3048 ExistingProposalStore, ProposalStoreTarget, SchemaApplicationRecord,
3049 SchemaApplicationRecordOp, SchemaChangeActivation, SchemaChangeJob,
3050 SchemaChangeOutcome, SchemaChangeProgressStatus, SchemaStore,
3051 },
3052 },
3053 error::{ErrorClass, ErrorOrigin},
3054 testing::test_memory,
3055 traits::{CanisterKind, Path},
3056 };
3057 use icydb_schema::{
3058 ConstraintFragment, ConstraintSourceKey, DeclaredEntityVersion, EntityFragment,
3059 EntitySourceKey, EntityStoreAssignment, ExpectedAcceptedHead, ExpectedSchemaFingerprint,
3060 FieldFragment, FieldInsertPolicy, FieldSourceKey, FieldType, NamedTypeFragment,
3061 RuleSourceKey, ScalarLiteral, ScalarType, SchemaCapability, SchemaFragment, SchemaName,
3062 SchemaProposal, SchemaSubmissionKey, SourceCheckExpr, SourceCheckInstruction,
3063 SourceRuleOperation, TargetDatabaseIdentity, TargetStoreIdentity, TargetedRuleFragment,
3064 TypeSourceKey,
3065 };
3066 use std::cell::RefCell;
3067
3068 fn drive_startup_recovery_to_completion<C: CanisterKind>(db: &Db<C>) {
3069 for _ in 0..1_024 {
3070 match continue_recovery(db).expect("test startup recovery page should succeed") {
3071 RecoveryProgress::Complete => return,
3072 RecoveryProgress::Pending => {}
3073 }
3074 }
3075 panic!("test startup recovery should complete within 1,024 bounded pages");
3076 }
3077
3078 #[cfg(feature = "migration")]
3079 use crate::db::schema::SchemaChangeReceipt;
3080 use crate::{
3081 db::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell},
3082 value::InputValue,
3083 };
3084 #[cfg(feature = "migration")]
3085 use icydb_schema::{
3086 EntityMigration, IndexFragment, IndexKeyFragment, RelationDeleteAction, RelationFragment,
3087 SchemaMigrationPlan, SchemaMigrationTransform, SchemaProposalDigest,
3088 };
3089
3090 fn version_one() -> DeclaredEntityVersion {
3091 DeclaredEntityVersion::try_new(1).expect("fixture version should admit")
3092 }
3093
3094 const ABORT_STORE_PATH: &str = "schema_application_tests::AbortStore";
3095 const EVOLUTION_STORE_PATH: &str = "schema_application_tests::EvolutionStore";
3096 #[cfg(feature = "migration")]
3097 const MIGRATION_STORE_PATH: &str = "schema_application_tests::MigrationStore";
3098 #[cfg(feature = "migration")]
3099 const MIGRATION_EXECUTION_STORE_PATH: &str =
3100 "schema_application_tests::MigrationExecutionStore";
3101 #[cfg(feature = "migration")]
3102 const MIGRATION_FINDING_STORE_PATH: &str = "schema_application_tests::MigrationFindingStore";
3103
3104 #[test]
3105 fn database_identity_state_capacity_combines_store_inventories_exactly() {
3106 let below = include_identity_state_count(0, 65_535)
3107 .expect("the first store inventory should remain below the database cap");
3108 let exact = include_identity_state_count(below, 1)
3109 .expect("the combined database boundary should admit");
3110 assert_eq!(exact, 65_536);
3111
3112 let error = include_identity_state_count(exact, 1)
3113 .expect_err("the next owner in another store must reject");
3114 assert_eq!(error.class(), ErrorClass::Unsupported);
3115 assert_eq!(error.origin(), ErrorOrigin::Identity);
3116 }
3117
3118 #[test]
3119 fn exact_empty_entity_proof_distinguishes_corruption_from_non_empty_input() {
3120 let corrupt = require_exact_empty_entity_count(None)
3121 .expect_err("uninspectable cardinality must fail closed");
3122 assert_eq!(corrupt.class(), ErrorClass::Corruption);
3123
3124 let non_empty = require_exact_empty_entity_count(Some(1))
3125 .expect_err("non-empty cardinality must reject removal");
3126 assert_eq!(non_empty.class(), ErrorClass::Unsupported);
3127 assert!(require_exact_empty_entity_count(Some(0)).is_ok());
3128 }
3129
3130 thread_local! {
3131 static ABORT_DATA: RefCell<DataStore> =
3132 RefCell::new(DataStore::init_journaled(test_memory(180)));
3133 static ABORT_INDEX: RefCell<IndexStore> =
3134 RefCell::new(IndexStore::init_journaled(test_memory(181)));
3135 static ABORT_SCHEMA: RefCell<SchemaStore> =
3136 RefCell::new(SchemaStore::init_journaled(test_memory(182)));
3137 static ABORT_JOURNAL: RefCell<JournalTailStore> =
3138 RefCell::new(JournalTailStore::init(test_memory(183)));
3139 static ABORT_REGISTRY: StoreRegistry = {
3140 let mut registry = StoreRegistry::new();
3141 registry.register_journaled_store(
3142 ABORT_STORE_PATH,
3143 &ABORT_DATA,
3144 &ABORT_INDEX,
3145 &ABORT_SCHEMA,
3146 &ABORT_JOURNAL,
3147 StoreAllocationIdentities::new_journaled(
3148 StoreAllocationIdentity::new(180, "icydb.test.application-abort.data.v1"),
3149 StoreAllocationIdentity::new(181, "icydb.test.application-abort.index.v1"),
3150 StoreAllocationIdentity::new(182, "icydb.test.application-abort.schema.v1"),
3151 StoreAllocationIdentity::new(183, "icydb.test.application-abort.journal.v1"),
3152 ),
3153 StoreRuntimeStorageCapabilities::journaled(),
3154 ).expect("abort journaled store should register");
3155 registry
3156 };
3157 }
3158
3159 #[cfg(feature = "migration")]
3160 thread_local! {
3161 static MIGRATION_EXECUTION_DATA: RefCell<DataStore> =
3162 RefCell::new(DataStore::init_journaled(test_memory(210)));
3163 static MIGRATION_EXECUTION_INDEX: RefCell<IndexStore> =
3164 RefCell::new(IndexStore::init_journaled(test_memory(211)));
3165 static MIGRATION_EXECUTION_SCHEMA: RefCell<SchemaStore> =
3166 RefCell::new(SchemaStore::init_journaled(test_memory(212)));
3167 static MIGRATION_EXECUTION_JOURNAL: RefCell<JournalTailStore> =
3168 RefCell::new(JournalTailStore::init(test_memory(213)));
3169 static MIGRATION_EXECUTION_REGISTRY: StoreRegistry = {
3170 let mut registry = StoreRegistry::new();
3171 registry.register_journaled_store(
3172 MIGRATION_EXECUTION_STORE_PATH,
3173 &MIGRATION_EXECUTION_DATA,
3174 &MIGRATION_EXECUTION_INDEX,
3175 &MIGRATION_EXECUTION_SCHEMA,
3176 &MIGRATION_EXECUTION_JOURNAL,
3177 StoreAllocationIdentities::new_journaled(
3178 StoreAllocationIdentity::new(210, "icydb.test.migration-execution.data.v1"),
3179 StoreAllocationIdentity::new(211, "icydb.test.migration-execution.index.v1"),
3180 StoreAllocationIdentity::new(212, "icydb.test.migration-execution.schema.v1"),
3181 StoreAllocationIdentity::new(213, "icydb.test.migration-execution.journal.v1"),
3182 ),
3183 StoreRuntimeStorageCapabilities::journaled(),
3184 ).expect("migration execution store should register");
3185 registry
3186 };
3187 }
3188
3189 #[cfg(feature = "migration")]
3190 thread_local! {
3191 static MIGRATION_DATA: RefCell<DataStore> =
3192 RefCell::new(DataStore::init_journaled(test_memory(200)));
3193 static MIGRATION_INDEX: RefCell<IndexStore> =
3194 RefCell::new(IndexStore::init_journaled(test_memory(201)));
3195 static MIGRATION_SCHEMA: RefCell<SchemaStore> =
3196 RefCell::new(SchemaStore::init_journaled(test_memory(202)));
3197 static MIGRATION_JOURNAL: RefCell<JournalTailStore> =
3198 RefCell::new(JournalTailStore::init(test_memory(203)));
3199 static MIGRATION_REGISTRY: StoreRegistry = {
3200 let mut registry = StoreRegistry::new();
3201 registry.register_journaled_store(
3202 MIGRATION_STORE_PATH,
3203 &MIGRATION_DATA,
3204 &MIGRATION_INDEX,
3205 &MIGRATION_SCHEMA,
3206 &MIGRATION_JOURNAL,
3207 StoreAllocationIdentities::new_journaled(
3208 StoreAllocationIdentity::new(200, "icydb.test.migration-validation.data.v1"),
3209 StoreAllocationIdentity::new(201, "icydb.test.migration-validation.index.v1"),
3210 StoreAllocationIdentity::new(202, "icydb.test.migration-validation.schema.v1"),
3211 StoreAllocationIdentity::new(203, "icydb.test.migration-validation.journal.v1"),
3212 ),
3213 StoreRuntimeStorageCapabilities::journaled(),
3214 ).expect("migration validation store should register");
3215 registry
3216 };
3217 }
3218
3219 #[cfg(feature = "migration")]
3220 thread_local! {
3221 static MIGRATION_FINDING_DATA: RefCell<DataStore> =
3222 RefCell::new(DataStore::init_journaled(test_memory(206)));
3223 static MIGRATION_FINDING_INDEX: RefCell<IndexStore> =
3224 RefCell::new(IndexStore::init_journaled(test_memory(207)));
3225 static MIGRATION_FINDING_SCHEMA: RefCell<SchemaStore> =
3226 RefCell::new(SchemaStore::init_journaled(test_memory(208)));
3227 static MIGRATION_FINDING_JOURNAL: RefCell<JournalTailStore> =
3228 RefCell::new(JournalTailStore::init(test_memory(209)));
3229 static MIGRATION_FINDING_REGISTRY: StoreRegistry = {
3230 let mut registry = StoreRegistry::new();
3231 registry.register_journaled_store(
3232 MIGRATION_FINDING_STORE_PATH,
3233 &MIGRATION_FINDING_DATA,
3234 &MIGRATION_FINDING_INDEX,
3235 &MIGRATION_FINDING_SCHEMA,
3236 &MIGRATION_FINDING_JOURNAL,
3237 StoreAllocationIdentities::new_journaled(
3238 StoreAllocationIdentity::new(206, "icydb.test.migration-finding.data.v1"),
3239 StoreAllocationIdentity::new(207, "icydb.test.migration-finding.index.v1"),
3240 StoreAllocationIdentity::new(208, "icydb.test.migration-finding.schema.v1"),
3241 StoreAllocationIdentity::new(209, "icydb.test.migration-finding.journal.v1"),
3242 ),
3243 StoreRuntimeStorageCapabilities::journaled(),
3244 ).expect("migration finding store should register");
3245 registry
3246 };
3247 }
3248
3249 thread_local! {
3250 static EVOLUTION_DATA: RefCell<DataStore> =
3251 RefCell::new(DataStore::init_journaled(test_memory(192)));
3252 static EVOLUTION_INDEX: RefCell<IndexStore> =
3253 RefCell::new(IndexStore::init_journaled(test_memory(193)));
3254 static EVOLUTION_SCHEMA: RefCell<SchemaStore> =
3255 RefCell::new(SchemaStore::init_journaled(test_memory(194)));
3256 static EVOLUTION_JOURNAL: RefCell<JournalTailStore> =
3257 RefCell::new(JournalTailStore::init(test_memory(195)));
3258 static EVOLUTION_REGISTRY: StoreRegistry = {
3259 let mut registry = StoreRegistry::new();
3260 registry.register_journaled_store(
3261 EVOLUTION_STORE_PATH,
3262 &EVOLUTION_DATA,
3263 &EVOLUTION_INDEX,
3264 &EVOLUTION_SCHEMA,
3265 &EVOLUTION_JOURNAL,
3266 StoreAllocationIdentities::new_journaled(
3267 StoreAllocationIdentity::new(192, "icydb.test.rule-evolution.data.v1"),
3268 StoreAllocationIdentity::new(193, "icydb.test.rule-evolution.index.v1"),
3269 StoreAllocationIdentity::new(194, "icydb.test.rule-evolution.schema.v1"),
3270 StoreAllocationIdentity::new(195, "icydb.test.rule-evolution.journal.v1"),
3271 ),
3272 StoreRuntimeStorageCapabilities::journaled(),
3273 ).expect("rule-evolution journaled store should register");
3274 registry
3275 };
3276 }
3277
3278 struct AbortCanister;
3279
3280 impl Path for AbortCanister {
3281 const PATH: &'static str = "schema_application_tests::AbortCanister";
3282 }
3283
3284 impl CanisterKind for AbortCanister {
3285 const COMMIT_MEMORY_ID: u8 = 184;
3286 const COMMIT_STABLE_KEY: &'static str = "icydb.test.application-abort.commit.v1";
3287 const STARTUP_MEMORY_ID: u8 = 186;
3288 const STARTUP_STABLE_KEY: &'static str = "icydb.test.application-abort.startup.control.v1";
3289 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 185;
3290 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3291 "icydb.test.application-abort.integrity.v1";
3292 }
3293
3294 struct EvolutionCanister;
3295
3296 impl Path for EvolutionCanister {
3297 const PATH: &'static str = "schema_application_tests::EvolutionCanister";
3298 }
3299
3300 impl CanisterKind for EvolutionCanister {
3301 const COMMIT_MEMORY_ID: u8 = 196;
3302 const COMMIT_STABLE_KEY: &'static str = "icydb.test.rule-evolution.commit.v1";
3303 const STARTUP_MEMORY_ID: u8 = 198;
3304 const STARTUP_STABLE_KEY: &'static str = "icydb.test.rule-evolution.startup.control.v1";
3305 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 197;
3306 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3307 "icydb.test.rule-evolution.integrity.v1";
3308 }
3309
3310 #[cfg(feature = "migration")]
3311 struct MigrationCanister;
3312
3313 #[cfg(feature = "migration")]
3314 impl Path for MigrationCanister {
3315 const PATH: &'static str = "schema_application_tests::MigrationCanister";
3316 }
3317
3318 #[cfg(feature = "migration")]
3319 impl CanisterKind for MigrationCanister {
3320 const COMMIT_MEMORY_ID: u8 = 204;
3321 const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration-validation.commit.v1";
3322 const STARTUP_MEMORY_ID: u8 = 206;
3323 const STARTUP_STABLE_KEY: &'static str =
3324 "icydb.test.migration-validation.startup.control.v1";
3325 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 205;
3326 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3327 "icydb.test.migration-validation.integrity.v1";
3328 }
3329
3330 #[cfg(feature = "migration")]
3331 struct MigrationExecutionCanister;
3332
3333 #[cfg(feature = "migration")]
3334 impl Path for MigrationExecutionCanister {
3335 const PATH: &'static str = "schema_application_tests::MigrationExecutionCanister";
3336 }
3337
3338 #[cfg(feature = "migration")]
3339 impl CanisterKind for MigrationExecutionCanister {
3340 const COMMIT_MEMORY_ID: u8 = 214;
3341 const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration-execution.commit.v1";
3342 const STARTUP_MEMORY_ID: u8 = 216;
3343 const STARTUP_STABLE_KEY: &'static str =
3344 "icydb.test.migration-execution.startup.control.v1";
3345 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 215;
3346 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3347 "icydb.test.migration-execution.integrity.v1";
3348 }
3349
3350 #[cfg(feature = "migration")]
3351 struct MigrationFindingCanister;
3352
3353 #[cfg(feature = "migration")]
3354 impl Path for MigrationFindingCanister {
3355 const PATH: &'static str = "schema_application_tests::MigrationFindingCanister";
3356 }
3357
3358 #[cfg(feature = "migration")]
3359 impl CanisterKind for MigrationFindingCanister {
3360 const COMMIT_MEMORY_ID: u8 = 210;
3361 const COMMIT_STABLE_KEY: &'static str = "icydb.test.migration-finding.commit.v1";
3362 const STARTUP_MEMORY_ID: u8 = 212;
3363 const STARTUP_STABLE_KEY: &'static str = "icydb.test.migration-finding.startup.control.v1";
3364 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 211;
3365 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3366 "icydb.test.migration-finding.integrity.v1";
3367 }
3368
3369 fn name(value: &str) -> SchemaName {
3370 SchemaName::try_new(value).expect("test schema name should admit")
3371 }
3372
3373 fn generated_check_proposal(
3374 expected_head: ExpectedAcceptedHead,
3375 submission_key: &str,
3376 include_check: bool,
3377 database: TargetDatabaseIdentity,
3378 store: TargetStoreIdentity,
3379 ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
3380 let entity_source = EntitySourceKey::try_new("Item").expect("entity source should admit");
3381 let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
3382 let score_source = FieldSourceKey::try_new("score").expect("score source should admit");
3383 let check_source =
3384 ConstraintSourceKey::try_new("score_non_negative").expect("check source should admit");
3385 let check = SourceCheckExpr::try_new(vec![
3386 SourceCheckInstruction::Field(score_source),
3387 SourceCheckInstruction::Literal(ScalarLiteral::Int(0)),
3388 SourceCheckInstruction::GreaterThanOrEqual,
3389 ])
3390 .expect("check expression should admit");
3391 let constraints = include_check
3392 .then(|| ConstraintFragment::check(name("score_non_negative"), check))
3393 .into_iter()
3394 .collect();
3395 let entity = EntityFragment::try_new(
3396 name("Item"),
3397 version_one(),
3398 vec![
3399 FieldFragment::new(
3400 name("id"),
3401 FieldType::Scalar(ScalarType::Nat64),
3402 false,
3403 FieldInsertPolicy::Required,
3404 None,
3405 ),
3406 FieldFragment::new(
3407 name("score"),
3408 FieldType::Scalar(ScalarType::Int64),
3409 false,
3410 FieldInsertPolicy::Required,
3411 None,
3412 ),
3413 ],
3414 vec![id_source],
3415 Vec::new(),
3416 Vec::new(),
3417 constraints,
3418 )
3419 .expect("entity should admit");
3420 let proposal = SchemaProposal::try_compose(
3421 vec![SchemaCapability::ACCEPTED_CHECKS],
3422 database,
3423 SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
3424 expected_head,
3425 vec![
3426 SchemaFragment::try_new(vec![entity], Vec::new())
3427 .expect("schema fragment should admit"),
3428 ],
3429 vec![EntityStoreAssignment::new(entity_source.clone(), store)],
3430 Vec::new(),
3431 None,
3432 )
3433 .expect("schema proposal should compose");
3434 (proposal, entity_source, check_source)
3435 }
3436
3437 #[cfg(feature = "migration")]
3438 #[derive(Clone, Copy, Eq, PartialEq)]
3439 enum ValidationMigrationShape {
3440 Clean,
3441 AllFindingFamilies,
3442 }
3443
3444 #[cfg(feature = "migration")]
3445 #[expect(
3446 clippy::too_many_lines,
3447 reason = "the fixture keeps both predecessor and candidate source contracts adjacent"
3448 )]
3449 fn validation_migration_proposal(
3450 shape: ValidationMigrationShape,
3451 current: bool,
3452 expected_head: ExpectedAcceptedHead,
3453 database: TargetDatabaseIdentity,
3454 store: TargetStoreIdentity,
3455 ) -> SchemaProposal {
3456 let entity_source = EntitySourceKey::try_new("MigratingItem")
3457 .expect("migration entity source should admit");
3458 let old_value =
3459 FieldSourceKey::try_new("old_value").expect("predecessor field source should admit");
3460 let current_value =
3461 FieldSourceKey::try_new("value").expect("candidate field source should admit");
3462 let target_entity = EntitySourceKey::try_new("MigrationTarget")
3463 .expect("migration target source should admit");
3464 let target_id = FieldSourceKey::try_new("id").expect("target id source should admit");
3465 let constraint = SourceCheckExpr::try_new(vec![
3466 SourceCheckInstruction::Field(current_value.clone()),
3467 SourceCheckInstruction::Literal(ScalarLiteral::Nat(8)),
3468 SourceCheckInstruction::LessThanOrEqual,
3469 ])
3470 .expect("candidate check should admit");
3471 let findings = shape == ValidationMigrationShape::AllFindingFamilies;
3472 let entity = EntityFragment::try_new(
3473 name("MigratingItem"),
3474 DeclaredEntityVersion::try_new(if current { 2 } else { 1 })
3475 .expect("migration version should admit"),
3476 vec![
3477 FieldFragment::new(
3478 name("id"),
3479 FieldType::Scalar(ScalarType::Nat64),
3480 false,
3481 FieldInsertPolicy::Required,
3482 None,
3483 ),
3484 FieldFragment::new(
3485 name(if current { "value" } else { "old_value" }),
3486 FieldType::Scalar(if current {
3487 ScalarType::Nat8
3488 } else {
3489 ScalarType::Int64
3490 }),
3491 false,
3492 FieldInsertPolicy::Required,
3493 None,
3494 ),
3495 ],
3496 vec![FieldSourceKey::try_new("id").expect("id source should admit")],
3497 current
3498 .then(|| {
3499 IndexFragment::try_new(
3500 name("value_unique"),
3501 vec![IndexKeyFragment::Field(current_value.clone())],
3502 true,
3503 None,
3504 )
3505 .expect("candidate index should admit")
3506 })
3507 .into_iter()
3508 .collect(),
3509 (current && findings)
3510 .then(|| {
3511 RelationFragment::try_new(
3512 name("value_target"),
3513 vec![current_value.clone()],
3514 target_entity.clone(),
3515 vec![target_id.clone()],
3516 RelationDeleteAction::Restrict,
3517 )
3518 .expect("candidate relation should admit")
3519 })
3520 .into_iter()
3521 .collect(),
3522 (current && findings)
3523 .then(|| ConstraintFragment::check(name("value_at_most_eight"), constraint))
3524 .into_iter()
3525 .collect(),
3526 )
3527 .expect("migration entity should admit");
3528 let target = EntityFragment::try_new(
3529 name("MigrationTarget"),
3530 version_one(),
3531 vec![FieldFragment::new(
3532 name("id"),
3533 FieldType::Scalar(ScalarType::Nat8),
3534 false,
3535 FieldInsertPolicy::Required,
3536 None,
3537 )],
3538 vec![target_id],
3539 Vec::new(),
3540 Vec::new(),
3541 Vec::new(),
3542 )
3543 .expect("migration relation target should admit");
3544 let migration = current.then(|| {
3545 SchemaMigrationPlan::try_new(vec![
3546 EntityMigration::try_new(
3547 entity_source.clone(),
3548 DeclaredEntityVersion::try_new(1).expect("predecessor should admit"),
3549 None,
3550 Vec::new(),
3551 vec![SchemaMigrationTransform::CheckedCast {
3552 from: old_value.clone(),
3553 to: current_value,
3554 target: ScalarType::Nat8,
3555 }],
3556 )
3557 .expect("migration transition should admit"),
3558 ])
3559 .expect("migration plan should admit")
3560 });
3561 let mut capabilities = Vec::new();
3562 if current && findings {
3563 capabilities.extend([
3564 SchemaCapability::ACCEPTED_CHECKS,
3565 SchemaCapability::SECONDARY_INDEXES,
3566 SchemaCapability::RESTRICTIVE_RELATIONS,
3567 ]);
3568 }
3569 if migration.is_some() {
3570 capabilities.push(SchemaCapability::VERSIONED_MIGRATIONS);
3571 }
3572 let mut entities = vec![entity];
3573 let mut assignments = vec![EntityStoreAssignment::new(entity_source.clone(), store)];
3574 if findings {
3575 entities.push(target);
3576 assignments.push(EntityStoreAssignment::new(target_entity, store));
3577 }
3578 SchemaProposal::try_compose(
3579 capabilities,
3580 database,
3581 SchemaSubmissionKey::try_new(if current {
3582 "migration-validation-v2"
3583 } else {
3584 "migration-validation-v1"
3585 })
3586 .expect("submission should admit"),
3587 expected_head,
3588 vec![
3589 SchemaFragment::try_new(entities, Vec::new())
3590 .expect("migration fragment should admit"),
3591 ],
3592 assignments,
3593 current
3594 .then_some(icydb_schema::SchemaRemoval::Field {
3595 entity: entity_source,
3596 field: old_value,
3597 })
3598 .into_iter()
3599 .collect(),
3600 migration,
3601 )
3602 .expect("migration proposal should compose")
3603 }
3604
3605 fn targeted_rule_proposal(
3606 expected_head: ExpectedAcceptedHead,
3607 submission_key: &str,
3608 operation: SourceRuleOperation,
3609 database: TargetDatabaseIdentity,
3610 store: TargetStoreIdentity,
3611 ) -> (SchemaProposal, EntitySourceKey, ConstraintSourceKey) {
3612 let entity_source =
3613 EntitySourceKey::try_new("Measured").expect("entity source should admit");
3614 let id_source = FieldSourceKey::try_new("id").expect("id source should admit");
3615 let value_source = FieldSourceKey::try_new("value").expect("value source should admit");
3616 let value_type = TypeSourceKey::try_new("Measure").expect("type source should admit");
3617 let rule_source = RuleSourceKey::try_new("limit").expect("rule source should admit");
3618 let constraint_source =
3619 ConstraintSourceKey::for_targeted_field_rule(&value_source, &value_type, &rule_source);
3620 let entity = EntityFragment::try_new(
3621 name("Measured"),
3622 version_one(),
3623 vec![
3624 FieldFragment::new(
3625 name("id"),
3626 FieldType::Scalar(ScalarType::Nat64),
3627 false,
3628 FieldInsertPolicy::Required,
3629 None,
3630 ),
3631 FieldFragment::new(
3632 name("value"),
3633 FieldType::Named(value_type.clone()),
3634 false,
3635 FieldInsertPolicy::Required,
3636 None,
3637 ),
3638 ],
3639 vec![id_source],
3640 Vec::new(),
3641 Vec::new(),
3642 vec![ConstraintFragment::targeted_rule(
3643 TargetedRuleFragment::new(value_source, value_type, name("limit"), operation),
3644 )],
3645 )
3646 .expect("targeted entity should admit");
3647 let proposal = SchemaProposal::try_compose(
3648 vec![SchemaCapability::ACCEPTED_CHECKS],
3649 database,
3650 SchemaSubmissionKey::try_new(submission_key).expect("submission key should admit"),
3651 expected_head,
3652 vec![
3653 SchemaFragment::try_new(
3654 vec![entity],
3655 vec![NamedTypeFragment::newtype(
3656 name("Measure"),
3657 FieldType::Scalar(ScalarType::Nat8),
3658 )],
3659 )
3660 .expect("schema fragment should admit"),
3661 ],
3662 vec![EntityStoreAssignment::new(entity_source.clone(), store)],
3663 Vec::new(),
3664 None,
3665 )
3666 .expect("schema proposal should compose");
3667 (proposal, entity_source, constraint_source)
3668 }
3669
3670 #[test]
3671 fn database_head_is_empty_only_when_every_store_root_is_absent() {
3672 assert_eq!(
3673 derive_accepted_head(&[("test::A", None), ("test::B", None)]),
3674 ExpectedAcceptedHead::Empty,
3675 );
3676 }
3677
3678 #[test]
3679 fn database_head_covers_store_path_revision_fingerprint_and_absence() {
3680 let first = derive_accepted_head(&[
3681 (
3682 "test::A",
3683 Some(AcceptedStoreHead {
3684 revision: 3,
3685 fingerprint: [0x11; 32],
3686 }),
3687 ),
3688 ("test::B", None),
3689 ]);
3690 let changed_fingerprint = derive_accepted_head(&[
3691 (
3692 "test::A",
3693 Some(AcceptedStoreHead {
3694 revision: 3,
3695 fingerprint: [0x12; 32],
3696 }),
3697 ),
3698 ("test::B", None),
3699 ]);
3700 let changed_absence = derive_accepted_head(&[
3701 (
3702 "test::A",
3703 Some(AcceptedStoreHead {
3704 revision: 3,
3705 fingerprint: [0x11; 32],
3706 }),
3707 ),
3708 (
3709 "test::B",
3710 Some(AcceptedStoreHead {
3711 revision: 1,
3712 fingerprint: [0x22; 32],
3713 }),
3714 ),
3715 ]);
3716
3717 assert_ne!(first, changed_fingerprint);
3718 assert_ne!(first, changed_absence);
3719 assert!(matches!(
3720 first,
3721 ExpectedAcceptedHead::Exact { revision: 3, .. }
3722 ));
3723 }
3724
3725 #[test]
3726 #[allow(
3727 clippy::too_many_lines,
3728 reason = "the end-to-end catalog assertion is clearer as one lifecycle test"
3729 )]
3730 fn generated_check_abort_retires_source_identity_and_allows_fresh_reproposal() {
3731 let database = TargetDatabaseIdentity::from_bytes([0x71; 32]);
3732 let store = TargetStoreIdentity::from_bytes([0x72; 32]);
3733 let (initial, entity_source, _) = generated_check_proposal(
3734 ExpectedAcceptedHead::Empty,
3735 "abort-initial",
3736 false,
3737 database,
3738 store,
3739 );
3740 let initial_candidate = lower_initial_schema_proposal(
3741 &initial,
3742 &[ProposalStoreTarget {
3743 path: "abort::Store",
3744 identity: store,
3745 }],
3746 )
3747 .expect("initial proposal should lower")
3748 .pop()
3749 .expect("initial proposal should produce one candidate");
3750 let (with_check, _, check_source) = generated_check_proposal(
3751 ExpectedAcceptedHead::Exact {
3752 revision: 1,
3753 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x73; 32]),
3754 },
3755 "abort-add-check",
3756 true,
3757 database,
3758 store,
3759 );
3760 let pending_candidate = lower_existing_schema_proposal(
3761 &with_check,
3762 &[ExistingProposalStore {
3763 path: "abort::Store",
3764 identity: store,
3765 bundle: initial_candidate.bundle(),
3766 }],
3767 )
3768 .expect("generated check should lower")
3769 .pop()
3770 .expect("generated check should produce one candidate");
3771 let entity_tag = pending_candidate
3772 .bundle()
3773 .source_bindings_for_tests()
3774 .entity(&entity_source)
3775 .expect("entity source should remain bound");
3776 let constraint_id = pending_candidate
3777 .bundle()
3778 .source_bindings_for_tests()
3779 .constraint(entity_tag, &check_source)
3780 .expect("generated check source should bind");
3781 let pending_snapshot = pending_candidate
3782 .bundle()
3783 .entity_snapshots()
3784 .get(&entity_tag)
3785 .expect("pending entity should exist");
3786 let activation = pending_snapshot
3787 .constraint_catalog()
3788 .activation(constraint_id)
3789 .expect("generated check should remain an activation");
3790 assert_eq!(activation.origin(), ConstraintOrigin::Generated);
3791
3792 let aborted = aborted_generated_row_local_candidate(
3793 pending_candidate.bundle(),
3794 entity_tag,
3795 constraint_id,
3796 )
3797 .expect("generated check abort should build one catalog-native candidate");
3798 let aborted_snapshot = aborted
3799 .bundle()
3800 .entity_snapshots()
3801 .get(&entity_tag)
3802 .expect("aborted entity should remain");
3803 assert!(
3804 aborted_snapshot
3805 .constraint_catalog()
3806 .activation(constraint_id)
3807 .is_none(),
3808 );
3809 assert_eq!(aborted_snapshot.row_layout(), pending_snapshot.row_layout());
3810 assert!(
3811 aborted
3812 .bundle()
3813 .source_bindings_for_tests()
3814 .constraint(entity_tag, &check_source)
3815 .is_none(),
3816 );
3817
3818 let reproposed = lower_existing_schema_proposal(
3819 &with_check,
3820 &[ExistingProposalStore {
3821 path: "abort::Store",
3822 identity: store,
3823 bundle: aborted.bundle(),
3824 }],
3825 )
3826 .expect("aborted generated check should be independently reproposable")
3827 .pop()
3828 .expect("reproposal should produce one candidate");
3829 let replacement_id = reproposed
3830 .bundle()
3831 .source_bindings_for_tests()
3832 .constraint(entity_tag, &check_source)
3833 .expect("reproposal should bind a fresh constraint identity");
3834 assert!(
3835 replacement_id > constraint_id,
3836 "aborted accepted IDs must remain retired",
3837 );
3838 }
3839
3840 #[test]
3841 fn targeted_rule_edit_abort_keeps_prior_accepted_semantics_and_source_identity() {
3842 let database = TargetDatabaseIdentity::from_bytes([0x81; 32]);
3843 let store = TargetStoreIdentity::from_bytes([0x82; 32]);
3844 let (initial, entity_source, constraint_source) = targeted_rule_proposal(
3845 ExpectedAcceptedHead::Empty,
3846 "targeted-abort-initial",
3847 SourceRuleOperation::NumericRangeInclusive {
3848 min: ScalarLiteral::Nat(0),
3849 max: ScalarLiteral::Nat(10),
3850 },
3851 database,
3852 store,
3853 );
3854 let initial_candidate = lower_initial_schema_proposal(
3855 &initial,
3856 &[ProposalStoreTarget {
3857 path: "abort::TargetedStore",
3858 identity: store,
3859 }],
3860 )
3861 .expect("initial targeted proposal should lower")
3862 .pop()
3863 .expect("initial targeted proposal should produce one candidate");
3864 let initial_bundle = initial_candidate.bundle();
3865 let entity_tag = initial_bundle
3866 .source_bindings_for_tests()
3867 .entity(&entity_source)
3868 .expect("entity source should bind");
3869 let constraint_id = initial_bundle
3870 .source_bindings_for_tests()
3871 .constraint(entity_tag, &constraint_source)
3872 .expect("targeted source should bind");
3873 let high_water = initial_bundle.entity_snapshots()[&entity_tag]
3874 .constraint_id_allocator()
3875 .high_water();
3876 let (edited, _, _) = targeted_rule_proposal(
3877 ExpectedAcceptedHead::Exact {
3878 revision: initial_bundle.revision().get(),
3879 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x83; 32]),
3880 },
3881 "targeted-abort-edit",
3882 SourceRuleOperation::NumericMaximumInclusive {
3883 value: ScalarLiteral::Nat(8),
3884 },
3885 database,
3886 store,
3887 );
3888 let staged = lower_existing_schema_proposal(
3889 &edited,
3890 &[ExistingProposalStore {
3891 path: "abort::TargetedStore",
3892 identity: store,
3893 bundle: initial_bundle,
3894 }],
3895 )
3896 .expect("targeted semantic edit should stage")
3897 .pop()
3898 .expect("targeted semantic edit should produce one candidate");
3899 let aborted =
3900 aborted_generated_row_local_candidate(staged.bundle(), entity_tag, constraint_id)
3901 .expect("targeted semantic edit should abort through catalog authority");
3902 let snapshot = &aborted.bundle().entity_snapshots()[&entity_tag];
3903
3904 assert!(
3905 snapshot
3906 .constraint_catalog()
3907 .activation(constraint_id)
3908 .is_none()
3909 );
3910 assert_eq!(snapshot.constraint_id_allocator().high_water(), high_water);
3911 assert_eq!(
3912 aborted
3913 .bundle()
3914 .source_bindings_for_tests()
3915 .constraint(entity_tag, &constraint_source),
3916 Some(constraint_id),
3917 );
3918 assert!(snapshot.constraints().iter().any(|constraint| {
3919 constraint.id() == constraint_id
3920 && matches!(
3921 constraint.kind(),
3922 AcceptedConstraintKind::TargetedRule { operation, .. }
3923 if matches!(
3924 operation.as_ref(),
3925 AcceptedRuleOperation::NumericRangeInclusive { .. }
3926 )
3927 )
3928 }));
3929 }
3930
3931 #[test]
3932 #[allow(
3933 clippy::too_many_lines,
3934 reason = "the staged publication, recovery, and promotion assertions form one lifecycle"
3935 )]
3936 fn generated_startup_driver_resumes_pending_activation_and_promotes_without_source_model() {
3937 let db = Db::<EvolutionCanister>::new(
3938 &EVOLUTION_REGISTRY,
3939 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
3940 );
3941 drive_startup_recovery_to_completion(&db);
3942 let empty_target =
3943 schema_application_target(&db).expect("empty evolution target should issue");
3944 let store_identity = empty_target
3945 .stores()
3946 .first()
3947 .expect("evolution store should register")
3948 .identity();
3949 let (initial, entity_source, constraint_source) = targeted_rule_proposal(
3950 empty_target.accepted_head().clone(),
3951 "targeted-recovery-initial",
3952 SourceRuleOperation::NumericRangeInclusive {
3953 min: ScalarLiteral::Nat(0),
3954 max: ScalarLiteral::Nat(10),
3955 },
3956 empty_target.database_identity(),
3957 store_identity,
3958 );
3959 assert!(matches!(
3960 apply_schema(&db, &initial)
3961 .expect("initial targeted proposal should publish")
3962 .outcome(),
3963 SchemaChangeOutcome::Applied { .. },
3964 ));
3965
3966 let direct_target =
3967 schema_application_target(&db).expect("direct evolution target should issue");
3968 let (direct_edit, _, _) = targeted_rule_proposal(
3969 direct_target.accepted_head().clone(),
3970 "targeted-direct-edit",
3971 SourceRuleOperation::NumericMaximumInclusive {
3972 value: ScalarLiteral::Nat(8),
3973 },
3974 direct_target.database_identity(),
3975 store_identity,
3976 );
3977 assert!(matches!(
3978 apply_schema(&db, &direct_edit)
3979 .expect("empty-domain semantic edit should publish directly")
3980 .outcome(),
3981 SchemaChangeOutcome::Applied { .. },
3982 ));
3983 let store = db
3984 .store_handle(EVOLUTION_STORE_PATH)
3985 .expect("evolution store should resolve");
3986 let direct = store
3987 .with_schema(SchemaStore::current_accepted_schema_bundle)
3988 .expect("directly edited bundle should remain readable")
3989 .expect("directly edited bundle should exist");
3990 let entity_tag = direct
3991 .source_bindings_for_tests()
3992 .entity(&entity_source)
3993 .expect("entity source should remain bound");
3994 let constraint_id = direct
3995 .source_bindings_for_tests()
3996 .constraint(entity_tag, &constraint_source)
3997 .expect("direct edit should preserve constraint identity");
3998 assert!(
3999 direct.entity_snapshots()[&entity_tag]
4000 .constraint_catalog()
4001 .activation(constraint_id)
4002 .is_none()
4003 );
4004 assert!(
4005 direct.entity_snapshots()[&entity_tag]
4006 .constraints()
4007 .iter()
4008 .any(|constraint| {
4009 constraint.id() == constraint_id
4010 && matches!(
4011 constraint.kind(),
4012 AcceptedConstraintKind::TargetedRule { operation, .. }
4013 if matches!(
4014 operation.as_ref(),
4015 AcceptedRuleOperation::NumericMaximumInclusive { .. }
4016 )
4017 )
4018 })
4019 );
4020
4021 let target = schema_application_target(&db).expect("staged evolution target should issue");
4022 let (edited, _, _) = targeted_rule_proposal(
4023 target.accepted_head().clone(),
4024 "targeted-recovery-edit",
4025 SourceRuleOperation::MultipleOf {
4026 divisor: ScalarLiteral::Nat(2),
4027 },
4028 target.database_identity(),
4029 store_identity,
4030 );
4031 let current = store
4032 .with_schema(SchemaStore::current_accepted_schema_bundle)
4033 .expect("accepted evolution bundle should remain readable")
4034 .expect("directly edited evolution bundle should exist");
4035 let staged = lower_existing_schema_proposal(
4036 &edited,
4037 &[ExistingProposalStore {
4038 path: EVOLUTION_STORE_PATH,
4039 identity: store_identity,
4040 bundle: ¤t,
4041 }],
4042 )
4043 .expect("targeted edit should stage")
4044 .pop()
4045 .expect("targeted edit should produce one candidate");
4046 assert_eq!(
4047 staged
4048 .bundle()
4049 .source_bindings_for_tests()
4050 .constraint(entity_tag, &constraint_source),
4051 Some(constraint_id),
4052 );
4053 let proof = DirectGeneratedRowLocalProof {
4054 candidate_index: 0,
4055 store,
4056 store_path: EVOLUTION_STORE_PATH,
4057 entity_tag,
4058 entity_path: staged.bundle().entity_snapshots()[&entity_tag]
4059 .entity_path()
4060 .to_string(),
4061 constraint_id,
4062 historical_rows: 0,
4063 };
4064 let final_candidates = final_candidates_for_pending_row_local_constraint(
4065 std::slice::from_ref(&staged),
4066 &PendingGeneratedRowLocalConstraint { proof },
4067 )
4068 .expect("final semantic replacement should derive without source input");
4069 let authorities = application_authorities(&db);
4070 let candidate_head =
4071 accepted_head_after_candidates(authorities.as_slice(), &final_candidates)
4072 .expect("final candidate head should derive");
4073 let digest = edited.digest().expect("proposal digest should derive");
4074 let job_id = derive_schema_change_job_id(
4075 target.database_identity(),
4076 edited.submission_key(),
4077 digest,
4078 target.accepted_head(),
4079 )
4080 .expect("job identity should derive");
4081 let receipt = crate::db::schema::SchemaChangeReceipt::new(
4082 target.database_identity(),
4083 edited.submission_key().clone(),
4084 digest,
4085 target.accepted_head().clone(),
4086 SchemaChangeOutcome::Pending {
4087 job: SchemaChangeJob::new(job_id),
4088 candidate_head,
4089 },
4090 )
4091 .expect("pending replacement receipt should admit");
4092 let record = SchemaApplicationRecord::new(
4093 receipt,
4094 vec![
4095 SchemaChangeActivation::new(
4096 store_identity,
4097 entity_tag.value(),
4098 constraint_id.get(),
4099 )
4100 .expect("replacement activation should admit"),
4101 ],
4102 )
4103 .expect("pending replacement record should admit");
4104 let operation =
4105 SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4106 publish_accepted_schema_candidates_with_application_record(
4107 vec![AcceptedSchemaPublication::new(
4108 EVOLUTION_STORE_PATH,
4109 store,
4110 current.revision(),
4111 &staged,
4112 )],
4113 operation,
4114 )
4115 .expect("staged replacement and record should publish atomically");
4116
4117 forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4118 drive_startup_recovery_to_completion(&db);
4119
4120 let recovered = store
4121 .with_schema(SchemaStore::current_accepted_schema_bundle)
4122 .expect("recovered staged bundle should decode")
4123 .expect("recovered staged bundle should exist");
4124 let recovered_snapshot = recovered.entity_snapshots()[&entity_tag].clone();
4125 let validating_catalog = recovered_snapshot
4126 .constraint_catalog()
4127 .clone()
4128 .with_validation_started(constraint_id)
4129 .expect("recovered replacement should enter validation");
4130 let mut validating_snapshots = recovered.entity_snapshots().clone();
4131 validating_snapshots.insert(
4132 entity_tag,
4133 recovered_snapshot.with_constraint_catalog(validating_catalog),
4134 );
4135 let validating_bundle = AcceptedSchemaRevisionBundle::new_with_source_bindings(
4136 recovered
4137 .revision()
4138 .checked_next()
4139 .expect("validation revision should remain available"),
4140 recovered.store_path(),
4141 recovered.enum_catalog().clone(),
4142 recovered.composite_catalog().clone(),
4143 recovered.source_bindings_for_tests().clone(),
4144 validating_snapshots,
4145 )
4146 .expect("validating replacement bundle should close");
4147 let validating_candidate = CandidateSchemaRevision::new(validating_bundle)
4148 .expect("validating replacement candidate should encode");
4149 let validating_activation = validating_candidate.bundle().entity_snapshots()[&entity_tag]
4150 .constraint_catalog()
4151 .activation(constraint_id)
4152 .expect("validating replacement activation should remain present");
4153 let validation_job = ConstraintValidationJob::start(
4154 entity_tag,
4155 validating_candidate.bundle().entity_snapshots()[&entity_tag]
4156 .entity_path()
4157 .to_string(),
4158 validating_activation,
4159 None,
4160 )
4161 .expect("validating replacement job should derive from accepted state");
4162 store
4163 .with_schema(|schema| {
4164 schema.validate_live_activation_transition(validating_candidate.bundle())?;
4165 schema.validate_constraint_validation_job_closure_with_change(
4166 validating_candidate.bundle(),
4167 Some(&validation_job),
4168 None,
4169 )
4170 })
4171 .expect("validating replacement transition and job should close");
4172 let startup_root = crate::db::RequestExecutionRoot::__new_runtime_root();
4173 let startup_session =
4174 crate::db::DbSession::<EvolutionCanister>::new(&EVOLUTION_REGISTRY, &startup_root);
4175
4176 assert_eq!(
4177 drive_generated_startup_recovery_page(
4178 &startup_session,
4179 &EVOLUTION_REGISTRY,
4180 edited.submission_key().as_str(),
4181 )
4182 .expect("generated startup should begin pending validation"),
4183 GeneratedStartupDriverStep::Recovering,
4184 );
4185 let mut terminal = false;
4186 for _ in 0..8 {
4187 match drive_generated_startup_recovery_page(
4188 &startup_session,
4189 &EVOLUTION_REGISTRY,
4190 edited.submission_key().as_str(),
4191 )
4192 .expect("generated startup should advance pending validation")
4193 {
4194 GeneratedStartupDriverStep::Recovering => {}
4195 GeneratedStartupDriverStep::Terminal => {
4196 terminal = true;
4197 break;
4198 }
4199 GeneratedStartupDriverStep::ApplyGeneratedSchema => {
4200 panic!("an exact pending receipt must resume instead of being resubmitted")
4201 }
4202 }
4203 }
4204 assert!(
4205 terminal,
4206 "empty historical domain should promote within bounded startup steps"
4207 );
4208 assert_eq!(
4209 observe_generated_startup_state::<EvolutionCanister>(
4210 &EVOLUTION_REGISTRY,
4211 edited.submission_key().as_str(),
4212 ),
4213 Ok(DatabaseStartupState::Ready),
4214 );
4215 let applied = super::exact_schema_application_receipt(
4216 &edited,
4217 edited
4218 .digest()
4219 .expect("proposal digest should remain stable"),
4220 )
4221 .expect("terminal generated receipt should remain readable")
4222 .expect("terminal generated receipt should remain present");
4223 assert!(matches!(
4224 applied.outcome(),
4225 SchemaChangeOutcome::Applied { .. }
4226 ));
4227 let promoted = store
4228 .with_schema(SchemaStore::current_accepted_schema_bundle)
4229 .expect("promoted bundle should remain readable")
4230 .expect("promoted bundle should exist");
4231 let snapshot = &promoted.entity_snapshots()[&entity_tag];
4232 assert!(
4233 snapshot
4234 .constraint_catalog()
4235 .activation(constraint_id)
4236 .is_none()
4237 );
4238 assert_eq!(
4239 promoted
4240 .source_bindings_for_tests()
4241 .constraint(entity_tag, &constraint_source),
4242 Some(constraint_id),
4243 );
4244 assert!(snapshot.constraints().iter().any(|constraint| {
4245 constraint.id() == constraint_id
4246 && matches!(
4247 constraint.kind(),
4248 AcceptedConstraintKind::TargetedRule { operation, .. }
4249 if matches!(
4250 operation.as_ref(),
4251 AcceptedRuleOperation::MultipleOf { .. }
4252 )
4253 )
4254 }));
4255 }
4256
4257 #[test]
4258 #[allow(
4259 clippy::too_many_lines,
4260 reason = "the durable pending job, startup failure, and retained finding assertions form one scenario"
4261 )]
4262 fn generated_startup_driver_persists_e223_for_a_retained_historical_finding() {
4263 let db = Db::<AbortCanister>::new(
4264 &ABORT_REGISTRY,
4265 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4266 );
4267 drive_startup_recovery_to_completion(&db);
4268 let empty_target =
4269 schema_application_target(&db).expect("empty application target should issue");
4270 let store_identity = empty_target
4271 .stores()
4272 .first()
4273 .expect("abort store should be registered")
4274 .identity();
4275 let (initial, _, _) = generated_check_proposal(
4276 empty_target.accepted_head().clone(),
4277 "startup-finding-initial",
4278 false,
4279 empty_target.database_identity(),
4280 store_identity,
4281 );
4282 apply_schema(&db, &initial).expect("initial generated schema should publish");
4283
4284 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
4285 let session = DbSession::<AbortCanister>::new(&ABORT_REGISTRY, &root);
4286 for id in 1..=257 {
4287 session
4288 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4289 entity: "Item".to_string(),
4290 patch: DynamicStructuralPatch::new(vec![
4291 (
4292 "id".to_string(),
4293 DynamicWriteCell::Value(InputValue::Nat64(id)),
4294 ),
4295 (
4296 "score".to_string(),
4297 DynamicWriteCell::Value(InputValue::Int64(if id == 257 {
4298 -1
4299 } else {
4300 1
4301 })),
4302 ),
4303 ]),
4304 })
4305 .expect("historical finding fixture row should commit");
4306 }
4307
4308 let target =
4309 schema_application_target(&db).expect("existing application target should issue");
4310 let (with_check, _, _) = generated_check_proposal(
4311 target.accepted_head().clone(),
4312 "startup-finding-pending",
4313 true,
4314 target.database_identity(),
4315 store_identity,
4316 );
4317 let pending = apply_schema(&db, &with_check)
4318 .expect("the first clean page should admit durable continuation");
4319 let SchemaChangeOutcome::Pending { job, .. } = pending.outcome() else {
4320 panic!("a 257-row domain must exceed the 256-row direct proof page")
4321 };
4322
4323 let mut terminal = false;
4324 for _ in 0..8 {
4325 match drive_generated_startup_recovery_page(
4326 &session,
4327 &ABORT_REGISTRY,
4328 with_check.submission_key().as_str(),
4329 )
4330 .expect("generated startup should retain a typed finding failure")
4331 {
4332 GeneratedStartupDriverStep::Recovering => {}
4333 GeneratedStartupDriverStep::Terminal => {
4334 terminal = true;
4335 break;
4336 }
4337 GeneratedStartupDriverStep::ApplyGeneratedSchema => {
4338 panic!("an exact pending receipt must not be resubmitted")
4339 }
4340 }
4341 }
4342 assert!(terminal, "the retained finding should become terminal");
4343 let failure = observe_generated_startup_state::<AbortCanister>(
4344 &ABORT_REGISTRY,
4345 with_check.submission_key().as_str(),
4346 )
4347 .expect_err("the retained finding must remain durably observable");
4348 assert_eq!(
4349 failure.kind(),
4350 crate::db::StartupFailureKind::SchemaReconciliation,
4351 );
4352 assert_eq!(
4353 failure.diagnostic().error_code(),
4354 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_CONSTRAINT_VIOLATION,
4355 );
4356 assert_eq!(
4357 ABORT_DATA.with(|store| store.borrow().len()),
4358 257,
4359 "terminal startup publication must not change historical rows",
4360 );
4361 assert!(matches!(
4362 continue_schema_application(&db, job.id(), None)
4363 .expect("the retained finding page should replay exactly")
4364 .status(),
4365 SchemaChangeProgressStatus::Findings { findings, .. } if !findings.is_empty(),
4366 ));
4367 }
4368
4369 #[test]
4370 #[allow(
4371 clippy::too_many_lines,
4372 reason = "the journaled abort, replay, and recovery assertions form one scenario"
4373 )]
4374 fn pending_generated_check_abort_is_atomic_terminal_and_replayable() {
4375 let db = Db::<AbortCanister>::new(
4376 &ABORT_REGISTRY,
4377 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4378 );
4379 drive_startup_recovery_to_completion(&db);
4380 let empty_target =
4381 schema_application_target(&db).expect("empty application target should issue");
4382 let store_identity = empty_target
4383 .stores()
4384 .first()
4385 .expect("abort store should be registered")
4386 .identity();
4387 let (initial, entity_source, _) = generated_check_proposal(
4388 empty_target.accepted_head().clone(),
4389 "abort-runtime-initial",
4390 false,
4391 empty_target.database_identity(),
4392 store_identity,
4393 );
4394 assert!(matches!(
4395 apply_schema(&db, &initial)
4396 .expect("initial application should publish")
4397 .outcome(),
4398 SchemaChangeOutcome::Applied { .. },
4399 ));
4400
4401 let target =
4402 schema_application_target(&db).expect("existing application target should issue");
4403 let (with_check, _, check_source) = generated_check_proposal(
4404 target.accepted_head().clone(),
4405 "abort-runtime-pending",
4406 true,
4407 target.database_identity(),
4408 store_identity,
4409 );
4410 let store = db
4411 .store_handle(ABORT_STORE_PATH)
4412 .expect("abort store should resolve");
4413 let current = store
4414 .with_schema(SchemaStore::current_accepted_schema_bundle)
4415 .expect("accepted bundle should remain readable")
4416 .expect("initial accepted bundle should exist");
4417 let pending_candidate = lower_existing_schema_proposal(
4418 &with_check,
4419 &[ExistingProposalStore {
4420 path: ABORT_STORE_PATH,
4421 identity: store_identity,
4422 bundle: ¤t,
4423 }],
4424 )
4425 .expect("pending generated check should lower")
4426 .pop()
4427 .expect("pending generated check should produce one candidate");
4428 let entity_tag = pending_candidate
4429 .bundle()
4430 .source_bindings_for_tests()
4431 .entity(&entity_source)
4432 .expect("entity source should bind");
4433 let constraint_id = pending_candidate
4434 .bundle()
4435 .source_bindings_for_tests()
4436 .constraint(entity_tag, &check_source)
4437 .expect("generated check source should bind");
4438 let digest = with_check.digest().expect("proposal digest should derive");
4439 let job_id = derive_schema_change_job_id(
4440 target.database_identity(),
4441 with_check.submission_key(),
4442 digest,
4443 target.accepted_head(),
4444 )
4445 .expect("job identity should derive");
4446 let receipt = crate::db::schema::SchemaChangeReceipt::new(
4447 target.database_identity(),
4448 with_check.submission_key().clone(),
4449 digest,
4450 target.accepted_head().clone(),
4451 SchemaChangeOutcome::Pending {
4452 job: SchemaChangeJob::new(job_id),
4453 candidate_head: ExpectedAcceptedHead::Exact {
4454 revision: pending_candidate.revision().get().saturating_add(2),
4455 fingerprint: ExpectedSchemaFingerprint::from_bytes([0x76; 32]),
4456 },
4457 },
4458 )
4459 .expect("pending receipt should admit");
4460 let record = SchemaApplicationRecord::new(
4461 receipt,
4462 vec![
4463 SchemaChangeActivation::new(
4464 store_identity,
4465 entity_tag.value(),
4466 constraint_id.get(),
4467 )
4468 .expect("application activation should admit"),
4469 ],
4470 )
4471 .expect("pending application record should admit");
4472 let operation =
4473 SchemaApplicationRecordOp::insert(&record).expect("pending insert should prepare");
4474 publish_accepted_schema_candidates_with_application_record(
4475 vec![AcceptedSchemaPublication::new(
4476 ABORT_STORE_PATH,
4477 store,
4478 current.revision(),
4479 &pending_candidate,
4480 )],
4481 operation,
4482 )
4483 .expect("pending candidate and record should publish atomically");
4484
4485 let started = continue_schema_application(&db, job_id, None)
4486 .expect("first continuation should durably start validation");
4487 assert_eq!(started.status(), &SchemaChangeProgressStatus::Started);
4488 let progress =
4489 abort_schema_application(&db, job_id, None).expect("pending application should abort");
4490 assert_eq!(progress.status(), &SchemaChangeProgressStatus::Aborted);
4491 assert!(matches!(
4492 progress.receipt().outcome(),
4493 SchemaChangeOutcome::Aborted { .. },
4494 ));
4495 let replay =
4496 abort_schema_application(&db, job_id, None).expect("terminal abort should replay");
4497 assert_eq!(replay, progress);
4498 assert_eq!(
4499 continue_schema_application(&db, job_id, None)
4500 .expect("continuation after abort should replay terminal state"),
4501 progress,
4502 );
4503
4504 let aborted = store
4505 .with_schema(SchemaStore::current_accepted_schema_bundle)
4506 .expect("accepted bundle should remain readable")
4507 .expect("aborted accepted bundle should exist");
4508 assert!(
4509 aborted
4510 .entity_snapshots()
4511 .get(&entity_tag)
4512 .expect("entity should remain after abort")
4513 .constraint_catalog()
4514 .activation(constraint_id)
4515 .is_none(),
4516 );
4517 assert!(
4518 aborted
4519 .source_bindings_for_tests()
4520 .constraint(entity_tag, &check_source)
4521 .is_none(),
4522 );
4523 assert!(
4524 store
4525 .with_schema(|schema| {
4526 schema.constraint_validation_job(entity_tag, constraint_id)
4527 })
4528 .expect("validation-job storage should remain readable")
4529 .is_none(),
4530 );
4531
4532 ABORT_DATA.with(|store| {
4533 *store.borrow_mut() = DataStore::init_journaled(test_memory(180));
4534 });
4535 ABORT_INDEX.with(|store| {
4536 *store.borrow_mut() = IndexStore::init_journaled(test_memory(181));
4537 });
4538 ABORT_SCHEMA.with(|store| {
4539 *store.borrow_mut() = SchemaStore::init_journaled(test_memory(182));
4540 });
4541 ABORT_JOURNAL.with(|store| {
4542 *store.borrow_mut() = JournalTailStore::init(test_memory(183));
4543 });
4544 forget_recovered_domain_for_tests(&db).expect("upgrade should reset recovery ownership");
4545 drive_startup_recovery_to_completion(&db);
4546 assert_eq!(
4547 abort_schema_application(&db, job_id, None)
4548 .expect("recovered terminal abort should replay"),
4549 progress,
4550 );
4551 assert!(
4552 store
4553 .with_schema(|schema| {
4554 schema.constraint_validation_job(entity_tag, constraint_id)
4555 })
4556 .expect("recovered validation-job storage should remain readable")
4557 .is_none(),
4558 );
4559 let startup_root = crate::db::RequestExecutionRoot::__new_runtime_root();
4560 let startup_session =
4561 crate::db::DbSession::<AbortCanister>::new(&ABORT_REGISTRY, &startup_root);
4562 assert_eq!(
4563 drive_generated_startup_recovery_page(
4564 &startup_session,
4565 &ABORT_REGISTRY,
4566 with_check.submission_key().as_str(),
4567 )
4568 .expect("an exact aborted generated submission should publish terminal startup state"),
4569 GeneratedStartupDriverStep::Terminal,
4570 );
4571 let failure = observe_generated_startup_state::<AbortCanister>(
4572 &ABORT_REGISTRY,
4573 with_check.submission_key().as_str(),
4574 )
4575 .expect_err("an aborted generated submission must not remain retryable forever");
4576 assert_eq!(
4577 failure.kind(),
4578 crate::db::StartupFailureKind::SchemaReconciliation,
4579 );
4580 assert_eq!(
4581 failure.diagnostic().error_code(),
4582 icydb_diagnostic_code::ErrorCode::RUNTIME_CONFLICT,
4583 );
4584 }
4585
4586 #[cfg(feature = "migration")]
4587 #[test]
4588 fn migration_planning_failures_retain_typed_public_classification() {
4589 use super::schema_migration_planning_error;
4590 use crate::db::schema::migration_planner::SchemaMigrationPlanningError;
4591 use icydb_diagnostic_code::{DiagnosticDetail, SchemaMigrationCode};
4592
4593 for (error, reason) in [
4594 (
4595 SchemaMigrationPlanningError::Unadopted,
4596 SchemaMigrationCode::Unadopted,
4597 ),
4598 (
4599 SchemaMigrationPlanningError::MissingMigration,
4600 SchemaMigrationCode::MissingMigration,
4601 ),
4602 (
4603 SchemaMigrationPlanningError::VersionGap,
4604 SchemaMigrationCode::VersionGap,
4605 ),
4606 (
4607 SchemaMigrationPlanningError::Downgrade,
4608 SchemaMigrationCode::Downgrade,
4609 ),
4610 (
4611 SchemaMigrationPlanningError::EmptyEntityVersionBump,
4612 SchemaMigrationCode::EmptyEntityVersionBump,
4613 ),
4614 (
4615 SchemaMigrationPlanningError::StaleAcceptedHead,
4616 SchemaMigrationCode::StaleAcceptedHead,
4617 ),
4618 (
4619 SchemaMigrationPlanningError::UnknownFromObject,
4620 SchemaMigrationCode::UnknownFromObject,
4621 ),
4622 (
4623 SchemaMigrationPlanningError::UnknownToObject,
4624 SchemaMigrationCode::UnknownToObject,
4625 ),
4626 (
4627 SchemaMigrationPlanningError::KindMismatch,
4628 SchemaMigrationCode::KindMismatch,
4629 ),
4630 (
4631 SchemaMigrationPlanningError::IdentityConflict,
4632 SchemaMigrationCode::IdentityConflict,
4633 ),
4634 (
4635 SchemaMigrationPlanningError::UnexplainedSchemaDifference,
4636 SchemaMigrationCode::UnexplainedSchemaDifference,
4637 ),
4638 (
4639 SchemaMigrationPlanningError::UnsupportedTransform,
4640 SchemaMigrationCode::UnsupportedTransform,
4641 ),
4642 (
4643 SchemaMigrationPlanningError::RekeyedCatalogInvalid,
4644 SchemaMigrationCode::CandidateMismatch,
4645 ),
4646 (
4647 SchemaMigrationPlanningError::CandidateMismatch,
4648 SchemaMigrationCode::CandidateMismatch,
4649 ),
4650 (
4651 SchemaMigrationPlanningError::CorruptLineage,
4652 SchemaMigrationCode::ProgressCorrupt,
4653 ),
4654 ] {
4655 let diagnostic = schema_migration_planning_error(error).diagnostic();
4656 assert_eq!(
4657 diagnostic.detail(),
4658 Some(&DiagnosticDetail::SchemaMigration { reason }),
4659 );
4660 assert_eq!(diagnostic.code(), reason.diagnostic_code());
4661 }
4662 }
4663
4664 #[cfg(feature = "migration")]
4665 #[test]
4666 #[expect(
4667 clippy::too_many_lines,
4668 reason = "the validation replay, staging, and unchanged-row assertions form one scenario"
4669 )]
4670 fn physical_migration_validation_is_bounded_staged_and_does_not_rewrite_rows() {
4671 use std::convert::Infallible;
4672
4673 use super::{defer_generated_schema_application_for_prepared_migration, migrate_schema};
4674 use crate::db::{
4675 data::StoreVisit,
4676 index::{IndexEntryValue, IndexId, IndexKey, IndexKeyKind},
4677 key_taxonomy::{PrimaryKeyComponent, PrimaryKeyValue},
4678 schema::{SchemaMigrationCommand, SchemaMigrationPhase},
4679 };
4680 use crate::types::EntityTag;
4681
4682 let db = Db::<MigrationCanister>::new(
4683 &MIGRATION_REGISTRY,
4684 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4685 );
4686 drive_startup_recovery_to_completion(&db);
4687 let initial_target = schema_application_target(&db).expect("initial target should issue");
4688 let store_identity = initial_target
4689 .stores()
4690 .first()
4691 .expect("migration store should exist")
4692 .identity();
4693 let initial = validation_migration_proposal(
4694 ValidationMigrationShape::Clean,
4695 false,
4696 initial_target.accepted_head().clone(),
4697 initial_target.database_identity(),
4698 store_identity,
4699 );
4700 apply_schema(&db, &initial).expect("initial schema should publish");
4701
4702 let session = DbSession::<MigrationCanister>::new(
4703 &MIGRATION_REGISTRY,
4704 &crate::db::RequestExecutionRoot::__new_runtime_root(),
4705 );
4706 for (id, value) in [(1, 7), (2, 8)] {
4707 session
4708 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4709 entity: "MigratingItem".to_string(),
4710 patch: DynamicStructuralPatch::new(vec![
4711 (
4712 "id".to_string(),
4713 DynamicWriteCell::Value(InputValue::Nat64(id)),
4714 ),
4715 (
4716 "old_value".to_string(),
4717 DynamicWriteCell::Value(InputValue::Int64(value)),
4718 ),
4719 ]),
4720 })
4721 .expect("predecessor row should insert");
4722 }
4723 let store = db
4724 .store_handle(MIGRATION_STORE_PATH)
4725 .expect("migration store should resolve");
4726 let row_bytes = || {
4727 store.with_data(|data| {
4728 let mut rows = Vec::new();
4729 let result: Result<(), Infallible> = data.visit_entries(|key, row| {
4730 rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
4731 Ok(StoreVisit::Continue)
4732 });
4733 result.expect("infallible row visit should complete");
4734 rows
4735 })
4736 };
4737 let before_rows = row_bytes();
4738
4739 let target = schema_application_target(&db).expect("migration target should issue");
4740 let proposal = validation_migration_proposal(
4741 ValidationMigrationShape::Clean,
4742 true,
4743 target.accepted_head().clone(),
4744 target.database_identity(),
4745 store_identity,
4746 );
4747 let plan = proposal
4748 .migration()
4749 .expect("migration plan should exist")
4750 .digest();
4751 let command = || SchemaMigrationCommand::Advance {
4752 expected_database: target.database_identity(),
4753 expected_head: target.accepted_head().clone(),
4754 expected_plan: plan,
4755 acknowledged_finding_page: None,
4756 };
4757 assert_eq!(
4758 migrate_schema(&db, &proposal, command())
4759 .unwrap_or_else(|error| {
4760 panic!(
4761 "physical migration should prepare: {:?}",
4762 error.diagnostic()
4763 )
4764 })
4765 .phase(),
4766 SchemaMigrationPhase::Prepared,
4767 );
4768 assert_eq!(
4769 migrate_schema(&db, &proposal, command())
4770 .expect("physical migration should enter validation")
4771 .phase(),
4772 SchemaMigrationPhase::Validating,
4773 );
4774 let record = super::load_schema_migration_record()
4775 .expect("migration record should remain readable")
4776 .expect("validating migration record should exist");
4777 let planned = super::recompile_active_physical_migration(&db, &proposal, &record)
4778 .expect("the exact active plan should recompile");
4779 for _ in 0..2 {
4780 let page = super::validate_migration_page(&db, &planned, record.progress())
4781 .expect("the same validation page should remain replayable");
4782 let (progress, staged, exhausted) = page.into_parts();
4783 assert!(progress.findings().is_empty());
4784 assert!(exhausted);
4785 super::stage_migration_index_entries(staged)
4786 .expect("staging before a cursor marker should be idempotent");
4787 }
4788 assert_eq!(
4789 store.with_index(IndexStore::len),
4790 2,
4791 "replaying an uncheckpointed page must retain one exact staged key per row",
4792 );
4793 let ready =
4794 migrate_schema(&db, &proposal, command()).expect("bounded validation should complete");
4795 assert_eq!(ready.phase(), SchemaMigrationPhase::ReadyToRewrite);
4796 assert_eq!(ready.rows_validated(), 2);
4797 assert!(ready.findings().is_empty());
4798 assert_eq!(row_bytes(), before_rows, "validation must not rewrite rows");
4799 assert_eq!(
4800 store.with_index(IndexStore::len),
4801 2,
4802 "the isolated candidate unique generation should be durably staged",
4803 );
4804 store.with_index_mut(|index| {
4805 for ordinal in 0..513_u64 {
4806 let component = ordinal.to_be_bytes();
4807 let key = IndexKey::new_from_components_with_primary_key_value(
4808 &IndexId::new(EntityTag::new(2), 0),
4809 IndexKeyKind::User,
4810 &[component],
4811 &PrimaryKeyValue::from(PrimaryKeyComponent::Nat64(ordinal)),
4812 )
4813 .expect("unrelated abort-scan key should build")
4814 .to_raw()
4815 .expect("unrelated abort-scan key should encode");
4816 index.insert(key, IndexEntryValue::presence());
4817 }
4818 });
4819 let abort = || SchemaMigrationCommand::Abort {
4820 expected_database: target.database_identity(),
4821 expected_head: target.accepted_head().clone(),
4822 expected_plan: plan,
4823 };
4824 let cleaning = migrate_schema(&db, &proposal, abort())
4825 .expect("the first bounded abort cleanup page should publish");
4826 assert_eq!(cleaning.phase(), SchemaMigrationPhase::ReadyToRewrite);
4827 assert_eq!(store.with_index(IndexStore::len), 513);
4828 let aborted =
4829 migrate_schema(&db, &proposal, abort()).expect("pre-rewrite migration should abort");
4830 assert_eq!(aborted.phase(), SchemaMigrationPhase::Aborted);
4831 assert_eq!(
4832 store.with_index(IndexStore::len),
4833 513,
4834 "abort must remove only planner-invisible candidate generations",
4835 );
4836 assert_eq!(
4837 row_bytes(),
4838 before_rows,
4839 "abort must retain predecessor rows"
4840 );
4841 assert!(
4842 !defer_generated_schema_application_for_prepared_migration(&db, &proposal)
4843 .expect("terminal aborted record must not block generated startup"),
4844 );
4845 }
4846
4847 #[cfg(feature = "migration")]
4848 #[test]
4849 #[expect(
4850 clippy::too_many_lines,
4851 reason = "the interrupted rewrite, recovery, final proof, and publication form one scenario"
4852 )]
4853 fn physical_migration_rewrite_recovers_and_publishes_one_complete_candidate() {
4854 use super::{
4855 defer_generated_schema_application_for_prepared_migration, migrate_schema,
4856 schema_migration_status_for_target,
4857 };
4858 use crate::db::{
4859 data::{CanonicalSlotReader, DecodedDataStoreKey, StoreVisit, StructuralSlotReader},
4860 schema::{
4861 MigrationRewriteInterruption, SchemaMigrationCommand, SchemaMigrationPhase,
4862 ensure_schema_migration_ready_for_ordinary_operations,
4863 interrupt_next_migration_rewrite_at,
4864 },
4865 };
4866 use crate::error::InternalError;
4867
4868 let db = Db::<MigrationExecutionCanister>::new(
4869 &MIGRATION_EXECUTION_REGISTRY,
4870 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
4871 );
4872 drive_startup_recovery_to_completion(&db);
4873 let initial_target = schema_application_target(&db).expect("initial target should issue");
4874 let store_identity = initial_target
4875 .stores()
4876 .first()
4877 .expect("migration execution store should exist")
4878 .identity();
4879 let initial = validation_migration_proposal(
4880 ValidationMigrationShape::Clean,
4881 false,
4882 initial_target.accepted_head().clone(),
4883 initial_target.database_identity(),
4884 store_identity,
4885 );
4886 apply_schema(&db, &initial).expect("initial schema should publish");
4887 let session = DbSession::<MigrationExecutionCanister>::new(
4888 &MIGRATION_EXECUTION_REGISTRY,
4889 &crate::db::RequestExecutionRoot::__new_runtime_root(),
4890 );
4891 for (id, value) in [(1, 7), (2, 8), (3, 9)] {
4892 session
4893 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4894 entity: "MigratingItem".to_string(),
4895 patch: DynamicStructuralPatch::new(vec![
4896 (
4897 "id".to_string(),
4898 DynamicWriteCell::Value(InputValue::Nat64(id)),
4899 ),
4900 (
4901 "old_value".to_string(),
4902 DynamicWriteCell::Value(InputValue::Int64(value)),
4903 ),
4904 ]),
4905 })
4906 .expect("predecessor row should insert");
4907 }
4908 let target = schema_application_target(&db).expect("migration target should issue");
4909 let proposal = validation_migration_proposal(
4910 ValidationMigrationShape::Clean,
4911 true,
4912 target.accepted_head().clone(),
4913 target.database_identity(),
4914 store_identity,
4915 );
4916 let plan = proposal
4917 .migration()
4918 .expect("migration plan should exist")
4919 .digest();
4920 let command = || SchemaMigrationCommand::Advance {
4921 expected_database: target.database_identity(),
4922 expected_head: target.accepted_head().clone(),
4923 expected_plan: plan,
4924 acknowledged_finding_page: None,
4925 };
4926 for expected in [
4927 SchemaMigrationPhase::Prepared,
4928 SchemaMigrationPhase::Validating,
4929 SchemaMigrationPhase::ReadyToRewrite,
4930 SchemaMigrationPhase::RewritingRows,
4931 ] {
4932 assert_eq!(
4933 migrate_schema(&db, &proposal, command())
4934 .expect("migration phase should advance")
4935 .phase(),
4936 expected,
4937 );
4938 }
4939
4940 for interruption in [
4941 MigrationRewriteInterruption::MarkerPersisted,
4942 MigrationRewriteInterruption::JournalPublished,
4943 MigrationRewriteInterruption::PhysicalApplied,
4944 ] {
4945 interrupt_next_migration_rewrite_at(interruption);
4946 migrate_schema(&db, &proposal, command())
4947 .expect_err("injected interruption should retain the rewrite marker");
4948
4949 forget_recovered_domain_for_tests(&db)
4950 .expect("upgrade should reset recovery ownership");
4951 drive_startup_recovery_to_completion(&db);
4952 }
4953
4954 let rebuilding = schema_migration_status_for_target(
4955 &db,
4956 &proposal,
4957 &schema_application_target(&db).expect("recovered target should issue"),
4958 )
4959 .expect("recovered status should remain readable");
4960 assert_eq!(rebuilding.phase(), SchemaMigrationPhase::RebuildingIndexes);
4961 assert_eq!(rebuilding.rows_rewritten(), 3);
4962 assert_eq!(
4963 migrate_schema(&db, &proposal, command())
4964 .expect("derived generations should complete")
4965 .phase(),
4966 SchemaMigrationPhase::FinalValidation,
4967 );
4968 assert_eq!(
4969 migrate_schema(&db, &proposal, command())
4970 .expect("final validation should complete")
4971 .phase(),
4972 SchemaMigrationPhase::Publishing,
4973 );
4974 let applied = migrate_schema(&db, &proposal, command())
4975 .expect("candidate publication should complete atomically");
4976 assert_eq!(applied.phase(), SchemaMigrationPhase::Applied);
4977 assert_eq!(applied.rows_rewritten(), 3);
4978 assert_eq!(applied.indexes_rebuilt(), 1);
4979 assert_ne!(applied.accepted_head(), target.accepted_head());
4980 let terminal_target = schema_application_target(&db).expect("terminal target should issue");
4981 let terminal_proposal = validation_migration_proposal(
4982 ValidationMigrationShape::Clean,
4983 true,
4984 terminal_target.accepted_head().clone(),
4985 terminal_target.database_identity(),
4986 store_identity,
4987 );
4988 assert!(
4989 !defer_generated_schema_application_for_prepared_migration(&db, &terminal_proposal,)
4990 .expect("terminal record must not block generated startup"),
4991 );
4992
4993 let store = db
4994 .store_handle(MIGRATION_EXECUTION_STORE_PATH)
4995 .expect("migration execution store should resolve");
4996 let runtime = db
4997 .accepted_runtime_entity_for_path("MigratingItem")
4998 .expect("published candidate entity should resolve");
4999 let selection = store
5000 .with_schema(|schema| {
5001 schema.current_accepted_catalog_selection(
5002 runtime.entity_tag(),
5003 runtime.entity_path(),
5004 runtime.store_path(),
5005 )
5006 })
5007 .expect("candidate selection should remain readable")
5008 .expect("candidate selection should exist");
5009 let contract = crate::db::data::AcceptedStructuralRowAuthority::from_catalog_selection(
5010 runtime.entity_path(),
5011 &selection,
5012 )
5013 .expect("candidate row authority should compile")
5014 .into_row_contract();
5015 let mut values = Vec::new();
5016 store
5017 .with_data(|data| {
5018 data.visit_entries(|key, row| {
5019 let decoded = DecodedDataStoreKey::try_from_raw(key)
5020 .expect("rewritten key should decode");
5021 let reader =
5022 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
5023 row, &contract,
5024 )
5025 .expect("rewritten row should use the candidate layout");
5026 reader
5027 .validate_primary_key(&decoded)
5028 .expect("rewritten row and key should remain bound");
5029 values.push(
5030 reader
5031 .required_value_by_contract(1)
5032 .expect("candidate value slot should decode"),
5033 );
5034 Ok::<StoreVisit, InternalError>(StoreVisit::Continue)
5035 })
5036 })
5037 .expect("rewritten row scan should complete");
5038 assert_eq!(
5039 values,
5040 vec![
5041 crate::value::Value::Nat64(7),
5042 crate::value::Value::Nat64(8),
5043 crate::value::Value::Nat64(9),
5044 ],
5045 );
5046 assert_eq!(store.with_index(IndexStore::len), 3);
5047 let accepted = store
5048 .with_schema(SchemaStore::current_accepted_schema_bundle)
5049 .expect("published candidate bundle should remain readable")
5050 .expect("published candidate bundle should exist");
5051 let entity_source = EntitySourceKey::try_new("MigratingItem")
5052 .expect("migration entity source should admit");
5053 let entity_tag = accepted
5054 .source_bindings_for_tests()
5055 .entity(&entity_source)
5056 .expect("candidate entity source should remain bound");
5057 let old_value =
5058 FieldSourceKey::try_new("old_value").expect("predecessor source should admit");
5059 let current_value =
5060 FieldSourceKey::try_new("value").expect("candidate source should admit");
5061 assert_eq!(
5062 accepted
5063 .source_bindings_for_tests()
5064 .field(entity_tag, &old_value),
5065 None,
5066 );
5067 assert!(
5068 accepted
5069 .source_bindings_for_tests()
5070 .field(entity_tag, ¤t_value)
5071 .is_some(),
5072 );
5073 ensure_schema_migration_ready_for_ordinary_operations()
5074 .expect("terminal publication must clear the database-wide gate");
5075 }
5076
5077 #[cfg(feature = "migration")]
5078 #[test]
5079 #[expect(
5080 clippy::too_many_lines,
5081 reason = "all four finding families share one ordered historical scan fixture"
5082 )]
5083 fn physical_migration_validation_reports_every_typed_finding_family_without_writes() {
5084 use std::convert::Infallible;
5085
5086 use super::migrate_schema;
5087 use crate::db::{
5088 data::StoreVisit,
5089 schema::{SchemaMigrationCommand, SchemaMigrationFindingKind, SchemaMigrationPhase},
5090 };
5091
5092 let db = Db::<MigrationFindingCanister>::new(
5093 &MIGRATION_FINDING_REGISTRY,
5094 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5095 );
5096 drive_startup_recovery_to_completion(&db);
5097 let initial_target = schema_application_target(&db).expect("initial target should issue");
5098 let store_identity = initial_target
5099 .stores()
5100 .first()
5101 .expect("migration finding store should exist")
5102 .identity();
5103 let initial = validation_migration_proposal(
5104 ValidationMigrationShape::AllFindingFamilies,
5105 false,
5106 initial_target.accepted_head().clone(),
5107 initial_target.database_identity(),
5108 store_identity,
5109 );
5110 apply_schema(&db, &initial).expect("initial finding schema should publish");
5111
5112 let session = DbSession::<MigrationFindingCanister>::new(
5113 &MIGRATION_FINDING_REGISTRY,
5114 &crate::db::RequestExecutionRoot::__new_runtime_root(),
5115 );
5116 session
5117 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5118 entity: "MigrationTarget".to_string(),
5119 patch: DynamicStructuralPatch::new(vec![(
5120 "id".to_string(),
5121 DynamicWriteCell::Value(InputValue::Nat64(7)),
5122 )]),
5123 })
5124 .expect("relation target should insert");
5125 for (id, value) in [(1, 9), (2, 8), (3, 7), (4, 7), (5, 300)] {
5126 session
5127 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5128 entity: "MigratingItem".to_string(),
5129 patch: DynamicStructuralPatch::new(vec![
5130 (
5131 "id".to_string(),
5132 DynamicWriteCell::Value(InputValue::Nat64(id)),
5133 ),
5134 (
5135 "old_value".to_string(),
5136 DynamicWriteCell::Value(InputValue::Int64(value)),
5137 ),
5138 ]),
5139 })
5140 .expect("predecessor finding row should insert");
5141 }
5142 let store = db
5143 .store_handle(MIGRATION_FINDING_STORE_PATH)
5144 .expect("migration finding store should resolve");
5145 let row_bytes = || {
5146 store.with_data(|data| {
5147 let mut rows = Vec::new();
5148 let result: Result<(), Infallible> = data.visit_entries(|key, row| {
5149 rows.push((key.as_bytes().to_vec(), row.as_bytes().to_vec()));
5150 Ok(StoreVisit::Continue)
5151 });
5152 result.expect("infallible row visit should complete");
5153 rows
5154 })
5155 };
5156 let before_rows = row_bytes();
5157
5158 let target = schema_application_target(&db).expect("migration target should issue");
5159 let proposal = validation_migration_proposal(
5160 ValidationMigrationShape::AllFindingFamilies,
5161 true,
5162 target.accepted_head().clone(),
5163 target.database_identity(),
5164 store_identity,
5165 );
5166 let plan = proposal
5167 .migration()
5168 .expect("migration plan should exist")
5169 .digest();
5170 let command = || SchemaMigrationCommand::Advance {
5171 expected_database: target.database_identity(),
5172 expected_head: target.accepted_head().clone(),
5173 expected_plan: plan,
5174 acknowledged_finding_page: None,
5175 };
5176 assert_eq!(
5177 migrate_schema(&db, &proposal, command())
5178 .expect("finding migration should prepare")
5179 .phase(),
5180 SchemaMigrationPhase::Prepared,
5181 );
5182 assert_eq!(
5183 migrate_schema(&db, &proposal, command())
5184 .expect("finding migration should enter validation")
5185 .phase(),
5186 SchemaMigrationPhase::Validating,
5187 );
5188 let rejected =
5189 migrate_schema(&db, &proposal, command()).expect("validation should report findings");
5190 assert_eq!(rejected.phase(), SchemaMigrationPhase::Rejected);
5191 assert_eq!(rejected.rows_validated(), 5);
5192 assert_eq!(
5193 rejected
5194 .findings()
5195 .iter()
5196 .map(crate::db::schema::SchemaMigrationFinding::kind)
5197 .collect::<Vec<_>>(),
5198 vec![
5199 SchemaMigrationFindingKind::Constraint,
5200 SchemaMigrationFindingKind::Relation,
5201 SchemaMigrationFindingKind::UniqueIndex,
5202 SchemaMigrationFindingKind::Transform,
5203 ],
5204 );
5205 assert_eq!(
5206 row_bytes(),
5207 before_rows,
5208 "rejected validation must not rewrite accepted rows"
5209 );
5210 assert_eq!(
5211 store.with_index(IndexStore::len),
5212 0,
5213 "a rejected page must not publish any staged generation"
5214 );
5215 }
5216
5217 #[cfg(feature = "migration")]
5218 #[test]
5219 fn exact_migration_retry_binds_the_terminal_head_not_the_predecessor_head() {
5220 use super::exact_migration_replay_target;
5221
5222 let db = Db::<EvolutionCanister>::new(
5223 &EVOLUTION_REGISTRY,
5224 crate::db::RequestExecutionRoot::__new_runtime_root().scope(),
5225 );
5226 drive_startup_recovery_to_completion(&db);
5227 let initial_target = schema_application_target(&db).expect("initial target should issue");
5228 let (proposal, _, _) = generated_check_proposal(
5229 initial_target.accepted_head().clone(),
5230 "migration-retry-initial",
5231 false,
5232 initial_target.database_identity(),
5233 initial_target
5234 .stores()
5235 .first()
5236 .expect("test store should exist")
5237 .identity(),
5238 );
5239 apply_schema(&db, &proposal).expect("initial schema should publish");
5240 let current_target = schema_application_target(&db).expect("current target should issue");
5241 assert_ne!(
5242 current_target.accepted_head(),
5243 initial_target.accepted_head(),
5244 );
5245
5246 let receipt = SchemaChangeReceipt::new(
5247 current_target.database_identity(),
5248 SchemaSubmissionKey::try_new("migration/retry")
5249 .expect("migration submission should admit"),
5250 SchemaProposalDigest::from_bytes([0x77; 32]),
5251 initial_target.accepted_head().clone(),
5252 SchemaChangeOutcome::Applied {
5253 accepted_head: current_target.accepted_head().clone(),
5254 },
5255 )
5256 .expect("terminal migration receipt should admit");
5257 let record = SchemaApplicationRecord::new(receipt, Vec::new())
5258 .expect("terminal migration record should admit");
5259
5260 assert_eq!(
5261 exact_migration_replay_target(&db, current_target.database_identity(), &record,)
5262 .expect("exact retry should resolve the terminal target"),
5263 current_target,
5264 );
5265 }
5266}