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