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