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